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)