Skip to content

Commit

Permalink
Add edit functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 committed Dec 17, 2024
1 parent ad154df commit 1284432
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 6 deletions.
34 changes: 34 additions & 0 deletions examples/reference/chat/ChatFeed.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"* **`header`** (Any): The header of the chat feed; commonly used for the title. Can be a string, pane, or widget.\n",
"* **`callback_user`** (str): The default user name to use for the message provided by the callback.\n",
"* **`callback_avatar`** (str, BytesIO, bytes, ImageBase): The default avatar to use for the entry provided by the callback. Takes precedence over `ChatMessage.default_avatars` if set; else, if None, defaults to the avatar set in `ChatMessage.default_avatars` if matching key exists. Otherwise defaults to the first character of the `callback_user`.\n",
"* **`edit_callback`** (callable): Callback to execute when a user edits a message. The signature must include the previous message value `contents`, the previous `user` name, and the component `instance`.\n",
"* **`help_text`** (str): If provided, initializes a chat message in the chat log using the provided help text as the message object and `help` as the user. This is useful for providing instructions, and will not be included in the `serialize` method by default.\n",
"* **`placeholder_text`** (str): The text to display next to the placeholder icon.\n",
"* **`placeholder_params`** (dict) Defaults to `{\"user\": \" \", \"reaction_icons\": {}, \"show_copy_icon\": False, \"show_timestamp\": False}` Params to pass to the placeholder `ChatMessage`, like `reaction_icons`, `timestamp_format`, `show_avatar`, `show_user`, `show_timestamp`.\n",
Expand Down Expand Up @@ -651,6 +652,39 @@
"message = chat_feed.send(\"Hello bots!\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Edit Callbacks\n",
"\n",
"An `edit_callback` can be attached to the `ChatFeed` to handle message edits.\n",
"\n",
"The signature must include the latest available message value `contents`, the index of the edited message, and the chat `instance`.\n",
"\n",
"Here, when the user edits the first message, the downstream message is updated to match the edited message."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def echo_callback(content, index, instance):\n",
" return content\n",
"\n",
"\n",
"def edit_callback(content, index, instance):\n",
" instance.objects[index + 1].object = content\n",
"\n",
"\n",
"chat_feed = pn.chat.ChatFeed(\n",
" edit_callback=edit_callback, callback=echo_callback, callback_user=\"Echo Guy\"\n",
")\n",
"chat_feed.send(\"Edit this\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
2 changes: 2 additions & 0 deletions examples/reference/chat/ChatMessage.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"* **`user`** (str): Name of the user who sent the message.\n",
"* **`avatar`** (str | BinaryIO): The avatar to use for the user. Can be a single character text, an emoji, or anything supported by `pn.pane.Image`. If not set, uses the first character of the name.\n",
"* **`default_avatars`** (Dict[str, str | BinaryIO]): A default mapping of user names to their corresponding avatars to use when the user is set but the avatar is not. You can modify, but not replace the dictionary. Note, the keys are *only* alphanumeric sensitive, meaning spaces, special characters, and case sensitivity is disregarded, e.g. `\"Chat-GPT3.5\"`, `\"chatgpt 3.5\"` and `\"Chat GPT 3.5\"` all map to the same value.\n",
"* **`edited`** (bool): An event that is triggered when the message is edited\n",
"* **`footer_objects`** (List): A list of objects to display in the column of the footer of the message.\n",
"* **`header_objects`** (List): A list of objects to display in the row of the header of the message.\n",
"* **`avatar_lookup`** (Callable): A function that can lookup an `avatar` from a user name. The function signature should be `(user: str) -> Avatar`. If this is set, `default_avatars` is disregarded.\n",
Expand All @@ -61,6 +62,7 @@
"* **`show_timestamp`** (bool): Whether to display the timestamp of the message.\n",
"* **`show_reaction_icons`** (bool): Whether to display the reaction icons.\n",
"* **`show_copy_icon`** (bool): Whether to show the copy icon.\n",
"* **`show_edit_icon`** (bool): Whether to display the edit icon.\n",
"* **`show_activity_dot`** (bool): Whether to show the activity dot.\n",
"* **`name`** (str): The title or name of the chat message widget, if any.\n",
"\n",
Expand Down
35 changes: 34 additions & 1 deletion panel/chat/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ class ChatFeed(ListPanel):
defaults to the avatar set in `ChatMessage.default_avatars` if matching key exists.
Otherwise defaults to the first character of the `callback_user`.""")

edit_callback = param.Callable(allow_refs=False, doc="""
Callback to execute when a user edits a message.
The signature must include the new message value `contents`,
the `message_index`, and the `instance`.""")

card_params = param.Dict(default={}, doc="""
Params to pass to Card, like `header`, `header_background`, `header_color`, etc.""")

Expand Down Expand Up @@ -232,7 +237,16 @@ def __init__(self, *objects, **params):
super().__init__(*objects, **params)

if self.help_text:
self.objects = [ChatMessage(self.help_text, user="Help", **message_params), *self.objects]
self.objects = [
ChatMessage(
self.help_text,
user="Help",
show_edit_icon=False,
show_copy_icon=False,
show_reaction_icons=False,
**message_params
), *self.objects
]

# instantiate the card's column
linked_params = dict(
Expand Down Expand Up @@ -368,6 +382,17 @@ def _replace_placeholder(self, message: ChatMessage | None = None) -> None:
except ValueError:
pass

async def _on_edit_message(self, event):
if self.edit_callback is None:
return
message = event.obj
contents = message.serialize()
index = self._chat_log.index(message)
if iscoroutinefunction(self.edit_callback):
await self.edit_callback(contents, index, self)
else:
self.edit_callback(contents, index, self)

def _build_message(
self,
value: dict,
Expand Down Expand Up @@ -396,7 +421,15 @@ def _build_message(
message_params["width"] = int(self.width - 80)
message_params.update(input_message_params)

if "show_edit_icon" not in message_params:
user = message_params.get("user", "")
message_params["show_edit_icon"] = (
bool(self.edit_callback) and
user.lower() not in (self.callback_user.lower(), "help")
)

message = ChatMessage(**message_params)
message.param.watch(self._on_edit_message, "edited")
return message

def _upsert_message(
Expand Down
5 changes: 5 additions & 0 deletions panel/chat/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,14 @@ class ChatAreaInput(_PnTextAreaInput):
Can only be set during initialization.""",
)

enter_pressed = param.Event(doc="""
Event when the enter key has been pressed.""")

_widget_type: ClassVar[type[Model]] = _bkChatAreaInput

_rename: ClassVar[Mapping[str, str | None]] = {
"value": None,
"enter_pressed": None,
**_PnTextAreaInput._rename,
}

Expand All @@ -99,5 +103,6 @@ def _process_event(self, event: ChatMessageEvent) -> None:
Clear value on shift enter key down.
"""
self.value = event.value
self.enter_pressed = True
with param.discard_events(self):
self.value = ""
2 changes: 2 additions & 0 deletions panel/chat/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ def send(
user = self.user
if avatar is None:
avatar = self.avatar
message_params["show_edit_icon"] = message_params.get("show_edit_icon", user == self.user)
return super().send(value, user=user, avatar=avatar, respond=respond, **message_params)

def stream(
Expand Down Expand Up @@ -739,4 +740,5 @@ def stream(
# so only set to the default when not a ChatMessage
user = user or self.user
avatar = avatar or self.avatar
message_params["show_edit_icon"] = message_params.get("show_edit_icon", user == self.user)
return super().stream(value, user=user, avatar=avatar, message=message, replace=replace, **message_params)
70 changes: 65 additions & 5 deletions panel/chat/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@
HTML, DataFrame, HTMLBasePane, Markdown,
)
from ..pane.media import Audio, Video
from ..pane.placeholder import Placeholder
from ..param import ParamFunction
from ..viewable import ServableMixin, Viewable
from ..widgets.base import Widget
from ..widgets.icon import ToggleIcon
from .icon import ChatCopyIcon, ChatReactionIcons
from .input import ChatAreaInput
from .utils import (
avatar_lookup, build_avatar_pane, serialize_recursively, stream_to,
)
Expand Down Expand Up @@ -215,6 +218,9 @@ class ChatMessage(Pane):
show_avatar = param.Boolean(default=True, doc="""
Whether to display the avatar of the user.""")

show_edit_icon = param.Boolean(default=True, doc="""
Whether to display the edit icon.""")

show_user = param.Boolean(default=True, doc="""
Whether to display the name of the user.""")

Expand All @@ -241,16 +247,16 @@ class ChatMessage(Pane):
user = param.Parameter(default="User", doc="""
Name of the user who sent the message.""")

edited = param.Event(doc="""
An event that is triggered when the message is edited.""")

_stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_message.css"]

# Declares whether Pane supports updates to the Bokeh model
_updates: ClassVar[bool] = True

def __init__(self, object=None, **params):
self._exit_stack = ExitStack()
self.chat_copy_icon = ChatCopyIcon(
visible=False, width=15, height=15, css_classes=["copy-icon"]
)
if params.get("timestamp") is None:
tz = params.get("timestamp_tz")
if tz is not None:
Expand All @@ -264,8 +270,12 @@ def __init__(self, object=None, **params):
params["reaction_icons"] = ChatReactionIcons(options=reaction_icons, default_layout=Row, sizing_mode=None)
self._internal = True
super().__init__(object=object, **params)
self.edit_icon = ToggleIcon(
icon="edit", active_icon="x", width=15, height=15,
stylesheets=self._stylesheets + self.param.stylesheets.rx(), css_classes=["edit-icon"],
)
self.chat_copy_icon = ChatCopyIcon(
visible=False, width=15, height=15, css_classes=["copy-icon"],
visible=False, width=15, height=15, css_classes=["edit-icon"],
stylesheets=self._stylesheets + self.param.stylesheets.rx(),
)
if not self.avatar:
Expand All @@ -285,15 +295,30 @@ def _build_layout(self):
self.param.watch(self._update_avatar_pane, "avatar")

self._object_panel = self._create_panel(self.object)
self._placeholder = Placeholder(
object=self._object_panel,
css_classes=["placeholder"],
stylesheets=self._stylesheets + self.param.stylesheets.rx(),
sizing_mode=None,
)
self._edit_area = ChatAreaInput(
value=self.object,
css_classes=["edit-area"],
stylesheets=self._stylesheets + self.param.stylesheets.rx()
)

self._update_chat_copy_icon()
self._update_edit_widgets()
self._center_row = Row(
self._object_panel,
self._placeholder,
css_classes=["center"],
stylesheets=self._stylesheets + self.param.stylesheets.rx(),
sizing_mode=None
)
self.param.watch(self._update_object_pane, "object")
self.param.watch(self._update_reaction_icons, "reaction_icons")
self.edit_icon.param.watch(self._toggle_edit, "value")
self._edit_area.param.watch(self._submit_edit, "enter_pressed")

self._user_html = HTML(
self.param.user, height=20,
Expand Down Expand Up @@ -338,6 +363,7 @@ def _build_layout(self):
)

self._icons_row = Row(
self.edit_icon,
self.chat_copy_icon,
self._render_reaction_icons(),
css_classes=["icons"],
Expand Down Expand Up @@ -574,6 +600,7 @@ def _update_object_pane(self, event=None):
if old is not new:
self._center_row[0] = new
self._update_chat_copy_icon()
self._update_edit_widgets()

@param.depends("avatar_lookup", "user", watch=True)
def _update_avatar(self):
Expand Down Expand Up @@ -608,6 +635,39 @@ def _update_chat_copy_icon(self):
self.chat_copy_icon.value = ""
self.chat_copy_icon.visible = False

def _update_edit_widgets(self):
object_panel = self._object_panel
if isinstance(object_panel, HTMLBasePane):
object_panel = object_panel.object
elif isinstance(object_panel, Widget):
object_panel = object_panel.value
if isinstance(object_panel, str) and self.show_edit_icon:
self.edit_icon.visible = True
else:
self.edit_icon.visible = False

def _toggle_edit(self, event):
if event.new:
with param.discard_events(self):
if isinstance(self._object_panel, HTMLBasePane):
self._edit_area.value = self._object_panel.object
elif isinstance(self._object_panel, Widget):
self._edit_area.value = self._object_panel.value
self._placeholder.update(object=self._edit_area)
else:
self._placeholder.update(object=self._object_panel)

def _submit_edit(self, event):
if isinstance(self.object, HTMLBasePane):
self.object.object = self._edit_area.value
elif isinstance(self.object, Widget):
self.object.value = self._edit_area.value
else:
self.object = self._edit_area.value
self.param.trigger("object")
self.edit_icon.value = False
self.edited = True

def _cleanup(self, root=None) -> None:
"""
Cleanup the exit stack.
Expand Down
15 changes: 15 additions & 0 deletions panel/dist/css/chat_message.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,18 @@
font-size: 1.25em;
line-height: 0.9em;
}

.edit-icon {
margin-top: 4px;
margin-inline: 3px;
}

.placeholder {
margin: 0;
width: calc(100% - 15px);
}

.edit-area {
/* for that smooth transition on a one line message */
height: 51.5px;
}
19 changes: 19 additions & 0 deletions panel/tests/chat/test_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -1598,3 +1598,22 @@ def append_callback(message, instance):
chat_feed.send("AB")
wait_until(lambda: chat_feed.objects[-1].object == "Echo: AB")
assert logs == ["AB", "Echo: ", "Echo: AB"]


@pytest.mark.xdist_group("chat")
class TestChatFeedEditCallback:

@pytest.mark.parametrize("edit_callback", [None, lambda content, index, instance: ""])
def test_show_edit_icon_callback(self, chat_feed, edit_callback):
chat_feed.edit_callback = edit_callback
chat_feed.send("Hello")
assert chat_feed[0].show_edit_icon is bool(edit_callback)

@pytest.mark.parametrize("user", ["User", "Assistant", "Help"])
def test_show_edit_icon_user(self, chat_feed, user):
chat_feed.edit_callback = lambda content, index, instance: ""
chat_feed.send("Hello", user=user)
if user == "User":
assert chat_feed[0].show_edit_icon
else:
assert not chat_feed[0].show_edit_icon
19 changes: 19 additions & 0 deletions panel/tests/chat/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -521,3 +521,22 @@ def test_scale_height(self):
assert chat_interface._chat_log.sizing_mode == "scale_height"
assert chat_interface._input_layout.sizing_mode == "stretch_width"
assert chat_interface._input_layout[0].sizing_mode == "stretch_width"


@pytest.mark.xdist_group("chat")
class TestChatInterfaceEditCallback:

@pytest.fixture
def chat_interface(self):
return ChatInterface()

def test_show_edit_icon_user(self, chat_interface):
chat_interface.edit_callback = lambda content, index, instance: ""
chat_interface.send("Hello")
assert chat_interface[0].show_edit_icon

@pytest.mark.parametrize("user", ["admin", "Assistant", "Help"])
def test_not_show_edit_icon_user(self, chat_interface, user):
chat_interface.edit_callback = lambda content, index, instance: ""
chat_interface.send("Hello", user=user)
assert not chat_interface[0].show_edit_icon
Loading

0 comments on commit 1284432

Please sign in to comment.