Source code for smarter.apps.prompt.views.detailviews.prompt_workbench_view

# pylint: disable=W0613,C0302
"""
PromptWorkbenchView is a Django class-based view responsible for serving the main prompt application page within the Smarter dashboard web app.

It hosts Smarter Chat, the React app of the LLMClient prompt workbench, as the web console hosts all of its
React apps: the template includes the app's Vite build assets from Django's static files, and renders the app's
root element, whose attributes configure it. The React app then takes over the UI from there.
"""

import traceback
from http import HTTPStatus
from typing import Optional

from django.conf import settings
from django.http import (
    HttpRequest,
)
from django.shortcuts import render

from smarter.apps.llmclient.models import (
    LLMClient,
    LLMClientHelper,
    get_cached_llmclient_by_request,
)
from smarter.apps.prompt.models import Prompt, PromptHelper
from smarter.apps.prompt.signals import prompt_session_invoked
from smarter.common.conf import smarter_settings
from smarter.common.const import (
    SMARTER_CHAT_SESSION_KEY_NAME,
)
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.lib import logging
from smarter.lib.django import waffle
from smarter.lib.django.http.shortcuts import (
    SmarterHttpResponseForbidden,
    SmarterHttpResponseNotFound,
    SmarterHttpResponseServerError,
)
from smarter.lib.django.shortcuts import reverse
from smarter.lib.django.views import (
    SmarterAuthenticatedNeverCachedWebView,
)
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.logging import WaffleSwitchedLoggerWrapper

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 SmarterPromptSession(SmarterHelperMixin): """Helper class that provides methods for creating a session key and client key.""" _chat: Optional[Prompt] = None _chat_helper: Optional[PromptHelper] = None _llmclient: Optional[LLMClient] = None request: Optional[HttpRequest] = None _session_key: str @property def formatted_class_name(self) -> str: """Returns a formatted string of the class name for logging purposes.""" class_name = f"{__name__}.{SmarterPromptSession.__name__}[{id(self)}]" return self.formatted_text(class_name)
[docs] def __init__(self, request: HttpRequest, session_key: str, *args, llmclient: Optional[LLMClient] = None, **kwargs): super().__init__() verbose_logger.debug( "SmarterPromptSession().__init__() called with session_key=%s, llmclient=%s", session_key, llmclient ) 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 llmclient: self._llmclient = llmclient self.user_profile = llmclient.user_profile # leaving this in place as a reminder that we need one or the other if not self.session_key and not self.llmclient: logger.error("%s - either session_key or llmclient must be provided", self.formatted_class_name) self._chat_helper = PromptHelper( request, *args, session_key=self.session_key, llmclient=self.llmclient, **kwargs ) self._chat = self._chat_helper.prompt verbose_logger.debug("%s - session established: %s", self.formatted_class_name, self.session_key) prompt_session_invoked.send(sender=self.__class__, instance=self, request=request)
def __str__(self) -> str: """ Returns a string representation of the session, naming its concrete class. :return: String representation of the session. :rtype: str """ return f"{self.__class__.__name__}[{id(self)}](llmclient={self.llmclient}, session_key={self.session_key})" def __repr__(self) -> str: """ Returns the developer representation of the session, which is the same as :meth:`__str__`. :return: Developer representation of the session. :rtype: str """ return self.__str__() def __hash__(self) -> int: """ Returns the hash of the session key, consistent with :meth:`__eq__`. :return: Hash of the session key. :rtype: int """ return hash(self.session_key) def __eq__(self, other: object) -> bool: """ Two sessions are equal when they have the same session key. :param other: The object to compare to. :type other: object :return: True if the session keys are equal, or ``NotImplemented`` if ``other`` is not a :class:`SmarterPromptSession`. :rtype: bool """ if not isinstance(other, SmarterPromptSession): return NotImplemented return self.session_key == other.session_key @property def session_key(self) -> str: """ The session key for this prompt session. This is used to identify the prompt 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 llmclient(self): return self._llmclient @property def prompt(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 PromptWorkbenchView(SmarterAuthenticatedNeverCachedWebView): """ Prompt app view for the Smarter web application. This view is responsible for serving the main prompt application page within the Smarter dashboard web app. It hosts Smarter Chat, a React app that is built with Vite into Django's static files, like every other React app of the web console. The React app then takes over the UI from there. **Key Features:** - **Django Template Integration:** The view renders ``react/smarter-chat.html``, whose ``react_smarter_chat`` template tag includes the React app's hashed JavaScript and CSS files, from its Vite manifest.json, and whose root div passes the app its configuration as attributes. - **ReactJS UI Bootstrapping:** The React app (https://github.com/smarter-sh/smarter-chat, cloned into smarter/react/packages/smarter-chat) renders the interactive prompt 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 LLMClients. - **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 prompt 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 prompt app, with the React app's assets and configuration context. **See Also:** - `PromptConfigView` — for the endpoint that provides configuration data to the React app. """ template_path = "react/smarter-chat.html" root_id = "smarter-chat-root" llmclient: Optional[LLMClient] = None llmclient_helper: Optional[LLMClientHelper] = None @property def log_stream_url(self) -> str: """ The url of the user's server log stream, which Smarter Chat displays in its Console. :returns: The url of :func:`smarter.apps.dashboard.views.terminal_emulator.api.streams.stream_user_logs`, or an empty string if log viewing in the browser is disabled, in which case Smarter Chat has no "Server Logs" tab. See :func:`smarter.apps.dashboard.views.terminal_emulator.enabled.server_logs_enabled`. :rtype: str """ # pylint: disable=C0415 from smarter.apps.dashboard.views.terminal_emulator.api.urls import ( DashboardLogsApiReverseNames, ) from smarter.apps.dashboard.views.terminal_emulator.enabled import ( server_logs_enabled, ) from smarter.apps.dashboard.views.terminal_emulator.names import ( DashboardLogsReverseNames, ) from smarter.apps.dashboard.views.views.urls import DashboardReverseNames if not server_logs_enabled(): return "" return reverse( DashboardReverseNames.namespace, DashboardLogsReverseNames.namespace, DashboardLogsApiReverseNames.namespace, DashboardLogsApiReverseNames.stream, )
[docs] def dispatch(self, request: HttpRequest, *args, **kwargs): """ Dispatch method to handle the request for the main prompt application page. This method is responsible for preparing and serving the Django template that bootstraps the ReactJS prompt UI within the Smarter dashboard web app. It passes the React app's configuration to the template, which renders it as the attributes of the app's root element. **Key Features:** - **Django Template Integration:** Uses Django's template system to render the main prompt page, with the React app's build assets and root div. - **ReactJS UI Bootstrapping:** The template includes the React build (JavaScript and CSS) from Django's static files. The React app then takes over rendering the interactive prompt 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 LLMClients. - **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 prompt 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 prompt app, with the React app's assets and configuration context. See Also -------- PromptConfigView : 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 llmclient # in the cache based on the request. self.llmclient = get_cached_llmclient_by_request(request=self.smarter_request) if not self.llmclient: self.llmclient_helper = LLMClientHelper( request=self.smarter_request, session_key=self.session_key, account=self.account, user=self.user, user_profile=self.user_profile, ) self.llmclient = self.llmclient_helper.llmclient if self.llmclient_helper.llmclient else None if self.llmclient: verbose_logger.debug( "%s.dispatch() - set llmclient=%s from self.llmclient_helper", self.formatted_class_name, self.llmclient, ) else: logger.debug( "%s.dispatch() - no llmclient found for request. Returning 404. Request URL: %s, Session Key: %s", self.formatted_class_name, request.build_absolute_uri(), self.session_key, ) return SmarterHttpResponseNotFound(request=request, error_message="LLMClient not found") except LLMClient.DoesNotExist: logger.debug( "%s.dispatch() - LLMClient.DoesNotExist. No llmclient found for request. Returning 404. Request URL: %s, Session Key: %s", self.formatted_class_name, request.build_absolute_uri(), self.session_key, ) return SmarterHttpResponseNotFound(request=request, error_message="LLMClient not found") # pylint: disable=broad-except except Exception as e: logger.error( "%s.dispatch() - Exception occurred while getting llmclient: %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.llmclient: logger.debug( "%s.dispatch() - no llmclient found for request after exception handling. Returning 404. Request URL: %s, Session Key: %s", self.formatted_class_name, request.build_absolute_uri(), self.session_key, ) return SmarterHttpResponseNotFound(request=request, error_message="LLMClient 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 prompt session key, csrf token, etc. context = { "smarter_chat": { "root_id": self.root_id, "llmclient_api_url": self.llmclient.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 prompt session, not the Django session. "django_session_cookie_name": settings.SESSION_COOKIE_NAME, # this is the Django session. "cookie_domain": settings.SESSION_COOKIE_DOMAIN or "", "react_debug_mode": waffle.switch_is_active(SmarterWaffleSwitches.ENABLE_REACTAPP_DEBUG_MODE), "smarter_request_id": self.generate_smarter_request_id(), "log_stream_url": self.log_stream_url, } } 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)