Source code for panel.widgets.chatbox

from __future__ import annotations

from typing import (
    Any, ClassVar, Dict, List, Optional, Tuple, Type, Union,
)

import param

from .._param import Margin
from ..layout import (
    Column, ListPanel, Row, Tabs,
)
from ..pane.base import PaneBase, panel as _panel
from ..pane.image import Image
from ..pane.markup import Markdown
from ..util.warnings import deprecated
from ..viewable import Layoutable, Viewable
from .base import CompositeWidget
from .button import Button, Toggle
from .input import StaticText, TextInput


[docs]class ChatRow(CompositeWidget): """ The ChatRow widget allows displaying a message adjacent to an icon and name. :Example: >>> ChatRow(name="You", value="Welcome!", show_name=True, align="start") """ value = param.List(doc="""The objects to display""") align_name = param.Selector(default="start", objects=["start", "end"], doc=""" Whether to show the name at the start or end of the row""") default_message_callable = param.Callable(default=None, doc=""" The type of Panel object or callable to use if an item in value is not already rendered as a Panel object; if None, uses the pn.panel function to render a displayable Panel object. If the item is not serializable, will fall back to pn.panel.""") icon = param.String(default=None, doc=""" The icon to display adjacent to the value""") liked = param.Boolean(default=False, doc=""" Whether a user liked the message""") margin = Margin(default=0, doc=""" Allows to create additional space around the component. May be specified as a two-tuple of the form (vertical, horizontal) or a four-tuple (top, right, bottom, left).""") show_name = param.Boolean(default=True, doc=""" Whether to show the name of the user""") show_like = param.Boolean(default=True, doc=""" Whether to show the like button""") styles = param.Dict(default={}, doc=""" Dictionary of CSS properties and values to apply message to the bubble.""") _composite_type: ClassVar[Type[ListPanel]] = Column def __init__( self, value: List[Any], icon: str = None, show_name: bool = True, show_like: bool = True, styles: Dict[str, str] = None, **params, ): deprecated("1.4", "pn.widgets.ChatRow", "pn.widgets.ChatMessage") bubble_styles = { "overflow-x": "auto", "overflow-y": "auto", "box-shadow": "rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px;", "padding": "0.5em", } if styles: bubble_styles.update(styles) icon_styles = dict((styles or {})) icon_styles.pop("background", None) icon_styles.pop("color", None) icon_styles.update( { "border-radius": "50%", "font-weight": "bold", "font-size": "1.2em", "text-align": "center", } ) if "background" not in bubble_styles: bubble_styles["background"] = "black" self._bubble_styles = bubble_styles super().__init__(value=value, icon=icon, **params) # create the chat icon icon_params = dict(width=48, height=48, align="center") if icon: self._icon = Image(icon, **icon_params) else: self._icon = None # create the chat bubble self._bubble = Column( *[self._serialize_obj(obj) for obj in self.value], align="center", margin=8, styles=bubble_styles, ) # create heart icon next to chat self._like = Toggle( name="♡", width=30, height=30, align="end", visible=show_like ) self._like.link(self, value="liked", bidirectional=True) self._like.param.watch(self._update_like, "value") # layout objects message_layout = { p: getattr(self, p) for p in Layoutable.param if p not in ("name", "height", "margin", "styles") and getattr(self, p) is not None } horizontal_align = message_layout.get("align", "start") if isinstance(horizontal_align, tuple): horizontal_align = horizontal_align[0] if icon: row_objects = (self._icon, self._bubble, self._like) else: row_objects = (self._bubble, self._like) if horizontal_align == "end": row_objects = row_objects[::-1] container_params = dict( sizing_mode="fixed", align=(horizontal_align, "center"), ) row = Row(*row_objects, **container_params) if show_name: if horizontal_align == "end": name_margin = (-15, 15, -15, 0) else: name_margin = (-15, 0, -15, 15) self._name = Markdown( object=self.name, margin=name_margin, align=(horizontal_align, "start"), styles={"font-size": "0.88em", "color": "grey"}, sizing_mode="fixed", ) if self.align_name == "start": row = Column(self._name, row, **container_params) else: row = Column(row, self._name, **container_params) else: self._name = None self._composite[:] = [row] def _serialize_obj( self, obj: Any ) -> Viewable: """ Convert an object to a Panel object. """ if isinstance(obj, Viewable): return obj stylesheets = ["p { margin-block-start: 0.2em; margin-block-end: 0.2em;}"] text_styles = {"color": self._bubble_styles.get("color")} try: if self.default_message_callable is None or issubclass( self.default_message_callable, PaneBase ): panel_obj = (self.default_message_callable or _panel)( obj, stylesheets=stylesheets, styles=text_styles ) else: panel_obj = self.default_message_callable(value=obj) except ValueError: panel_obj = _panel(obj, stylesheets=stylesheets, styles=text_styles) if panel_obj.sizing_mode is None: panel_obj.sizing_mode = "stretch_width" if "overflow-wrap" not in panel_obj.styles: panel_obj.styles.update({"overflow-wrap": "break-word"}) return panel_obj def _update_like(self, event: param.parameterized.Event): """ Update the like button when the user clicks it. """ self.liked = event.new if self.liked: event.obj.name = "❤️" else: event.obj.name = "♡"
[docs]class ChatBox(CompositeWidget): """ The ChatBox widget displays a conversation between multiple users composed of users' icons, names, messages, and likes. Reference: https://panel.holoviz.org/reference/widgets/ChatBox.html :Example: >>> ChatBox(value=[{"You": "Hello!"}, {"Bot": ["How may I help?", "I'm a bot."]}]) """ value = param.List(default=[], item_type=Dict, doc=""" List of messages as dicts, mapping user to message(s), e.g. `[{'You': ['Welcome!', 'Good bye!']}]` The message(s) can be any Python object that can be rendered by Panel.""") primary_name = param.String(default=None, doc="""Name of the primary user (the one who inputs messages); the first key found in value will be used if unspecified.""") allow_input = param.Boolean(default=True, doc=""" Whether to allow the primary user to interactively enter messages.""") allow_likes = param.Boolean(default=False, doc=""" Whether to allow the primary user to interactively like messages.""") ascending = param.Boolean(default=False, doc=""" Whether to display messages in ascending time order. If true, the latest messages and message_input_widgets will be at the bottom of the chat box. Otherwise, they will be at the top.""") default_message_callable = param.Callable(default=None, doc=""" The type of Panel object to use for items in value if they are not already rendered as a Panel object; if None, uses the pn.panel function to render a displayable Panel object. If the item is not serializable, will fall back to pn.panel. """) message_icons = param.Dict(default={}, doc=""" Dictionary mapping name of messages to their icons, e.g. `[{'You': 'path/to/icon.png'}]`""") message_colors = param.Dict(default={}, doc=""" Dictionary mapping name of messages to their colors, e.g. `[{'You': 'red'}]`""") message_hue = param.Integer(default=None, bounds=(0, 360), doc=""" Base hue of the message bubbles if message_colors is not specified for a user.""") message_input_widgets = param.List(default=[TextInput], doc=""" List of widgets to use for message input. Multiple widgets will be nested under tabs.""") show_names = param.Boolean(default=True, doc=""" Whether to show chat participant's names below the message.""") _composite_type: ClassVar[Type[ListPanel]] = Column def __init__(self, **params): deprecated("1.4", "pn.widgets.ChatBox", "pn.chat.ChatInterface") # set up parameters if params.get("width") and params.get("height") and "sizing_mode" not in params: params["sizing_mode"] = None super().__init__(**params) # Set up layout layout = { p: getattr(self, p) for p in Layoutable.param if p not in ("name", "styles", "margin") and getattr(self, p) is not None } chat_layout = layout.copy() chat_layout.update( styles={ "overflow-y": "auto", "overflow-x": "auto", "flex-direction": "column" if self.ascending else "column-reverse", }, ) self._chat_title = StaticText( value=f"{self.name}", styles={"font-size": "1.5em"}, align="center", ) self._chat_log = Column(**chat_layout) self._scroll_button = Button( name="Scroll to latest", align="center", sizing_mode="fixed", width=115, height=35, margin=0, ) self._add_scroll_callback(self._scroll_button, "clicks") self._current_hue = self.message_hue if self._current_hue: self._default_colors = self._generate_default_hsl(self._current_hue) else: self._default_colors = [] box_objects = [self._chat_title] if self.name else [] box_objects.append(self._chat_log) if self.ascending: box_objects.insert(0, self._scroll_button) else: box_objects.append(self._scroll_button) if self.allow_input: self._attach_input(box_objects, layout) else: self._message_inputs = {} self._send_button = None self._composite[:] = box_objects # add interactivity self.param.watch(self._refresh_log, "value") # populate with initial value self.param.trigger("value") def _generate_default_hsl(self, hue: int | None, increment: int = 0) -> List[str]: hue += increment if hue > 360: hue -= 360 self._current_hue = hue return [ (f"hsl({hue}, 45%, 50%)", "white"), (f"hsl({hue}, 30%, 55%)", "white"), (f"hsl({hue}, 15%, 60%)", "white"), ] def _add_scroll_callback(self, obj: Viewable, what: str): code = """ const outerColumn = document.querySelector(".bk-Column") const column = outerColumn.shadowRoot.querySelector(".bk-Column") """ if self.ascending: code += "\ncolumn.scrollTop = column.scrollHeight" else: code += "\ncolumn.scrollTop = -column.scrollHeight" obj.jscallback( args={"chat_log": self._chat_log}, **{what: code}, ) def _link_disabled_loading(self, obj: Viewable): """ Link the disabled and loading attributes of the chat box to the given object. """ for key in ["disabled", "loading"]: setattr(obj, key, getattr(self, key)) self.link(obj, **{key: key}) def _attach_input(self, box_objects: List, layout: Dict[str, str]) -> None: """ Attach the input widgets to the chat box. """ self._message_inputs = {} for message_input_widget in self.message_input_widgets: key = message_input_widget.name or message_input_widget.__class__.__name__ if isinstance(message_input_widget, type): # check if instantiated message_input_widget = message_input_widget() self._message_inputs[key] = message_input_widget self._send_button = Button( name="Send", button_type="default", sizing_mode="stretch_width", max_width=100, height=35, ) self._send_button.on_click(self._enter_message) row_layout = layout.copy() row_layout.pop("width", None) row_layout.pop("height", None) row_layout["sizing_mode"] = "stretch_width" input_items = Tabs() for message_input_name, message_input in self._message_inputs.items(): self._link_disabled_loading(message_input) message_input.sizing_mode = "stretch_width" # for longer form messages, like TextArea / Ace, don't # submit when clicking away; only if they manually click # the send button if isinstance(message_input, TextInput): message_input.param.watch(self._enter_message, "value") self._add_scroll_callback(message_input, "value") send_button = self._send_button.clone() self._link_disabled_loading(send_button) message_row = Row(message_input, send_button, **row_layout) input_items.append((message_input_name, message_row)) if len(self._message_inputs) == 1: input_items = input_items[0] # if only a single input, don't use tabs if self.ascending: box_objects.append(input_items) else: box_objects.insert(0, input_items) @staticmethod def _get_name(dict_: Dict[str, str]) -> str: """ Get the name of the user who sent the message. """ return next(iter(dict_)) def _separate_user_message( self, user_message: Dict[str, Union[List[Any], Any]] ) -> Tuple[str, str]: """ Separate the user and message from a dictionary. """ if len(user_message) == 1: user = self._get_name(user_message) message_contents = user_message[user] elif "role" in user_message and "content" in user_message: user = user_message["role"] message_contents = user_message["content"] else: raise ValueError( f"Expected a dictionary with one key-value pair, e.g. " f"{{'User': 'Message'}} or two key-value pairs with " f"'role' and 'content' as keys, e.g. " f"{{'role': 'User', 'content': 'Message'}}, " f"but got {user_message}" ) return user, message_contents def _instantiate_message_row( self, user: str, message_contents: Union[Any, List[Any]], show_name: bool, ) -> ChatRow: """ Instantiate a ChatRow object. """ if self.primary_name is None: if self.value: self.primary_name = self._get_name(self.value[0]) else: self.primary_name = "You" # try to get input color; if not generate one and save if user in self.message_colors: colors = self.message_colors[user] background, color = ( colors if isinstance(colors, tuple) else (colors, "black") ) elif self.message_hue: if len(self._default_colors) == 0: self._default_colors = self._generate_default_hsl( self._current_hue, increment=88 ) background, color = self._default_colors.pop() self.message_colors[user] = (background, color) else: if user == self.primary_name: background, color = ("rgb(99, 139, 226)", "white") else: background, color = ("rgb(235, 235, 235)", "black") self.message_colors[user] = (background, color) # try to get input icon message_icon = self.message_icons.get(user, None) is_other_user = user != self.primary_name align = "start" if is_other_user else "end" if not isinstance(message_contents, list): message_contents = [message_contents] message_row = ChatRow( name=user, value=message_contents, icon=message_icon, show_name=show_name, show_like=self.allow_likes, align=align, align_name="start" if self.ascending else "end", default_message_callable=self.default_message_callable, styles={ "background": background, "color": color, "border-radius": "1em", }, ) return message_row def _refresh_log(self, event: Optional[param.parameterized.Event] = None) -> None: """ Refresh the chat log for complete replacement of all messages. """ user_messages = event.new message_rows = [] previous_user = None for i, user_message in enumerate(user_messages): user, message_contents = self._separate_user_message(user_message) # only show name if it's a new user and only if show_names is True show_name = user != previous_user if self.show_names else False previous_user = user message_row = self._instantiate_message_row( user, message_contents, show_name ) # try to rebuild liked status if len(self._chat_log.objects) > i: original_message_row = self._chat_log.objects[i] if original_message_row.value == message_row.value: message_row.liked = original_message_row.liked message_rows.append(message_row) self._chat_log.objects = message_rows def _enter_message(self, _: Optional[param.parameterized.Event] = None) -> None: """ Append the message from the text input when the user presses Enter. """ for message_input in self._message_inputs.values(): # first set value_input to "" or else it triggers twice in notebook # strangely, it doesn't occur when served on the browser if hasattr(message_input, "value_input"): message_input.value_input = "" message = message_input.value if message: break else: return # no message entered across all inputs user = self.primary_name or "You" self.append({user: message}) @property def rows(self) -> List[ChatRow]: """ Returns a list of ChatRow objects. """ return self._chat_log.objects
[docs] def append(self, user_message: Dict[str, Union[List[Any], Any]]) -> None: """ Appends a message to the chat log. Arguments --------- user_message (dict): Dictionary mapping user to message. """ if not isinstance(user_message, dict): raise ValueError(f"Expected a dictionary, but got {user_message}") self.value.append(user_message) self.param.trigger("value")
[docs] def extend(self, user_messages: List[Dict[str, Union[List[Any], Any]]]) -> None: """ Extends the chat log with new users' messages. Arguments --------- user_messages (list): List of user messages to add. """ self.value.extend(user_messages) self.param.trigger("value")
[docs] def insert(self, index: int, user_message: Dict[str, Union[List[Any], Any]]) -> None: """ Inserts a message into the chat log at the given index. Arguments --------- index (int): Index to insert the message at. user_message (dict): Dictionary mapping user to message. """ self.value.insert(index, user_message) self.param.trigger("value")
[docs] def pop(self, index: int = -1) -> Dict[str, Union[List[Any], Any]]: """ Pops the last message from the chat log. Arguments --------- index (int): Index of the message to pop; defaults to the last message. Returns ------- user_message (dict): Dictionary mapping user to message. """ value = self.value.pop(index) self.param.trigger("value") return value
[docs] def replace(self, index: int, user_message: Dict[str, Union[List[Any], Any]]): """ Replaces a message in the chat log at the given index. Arguments --------- index (int): Index to replace the message at. user_message (dict): Dictionary mapping user to message. """ self.value[index] = user_message self.param.trigger("value")
[docs] def clear(self) -> None: """ Clears the chat log. """ self.value = []
def __len__(self) -> int: return len(self.value)