Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ChatMessage & ChatFeed edit functionality #7559

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Releases

## Version 1.6.0

### Features

- Introduces `ChatMessage` and `ChatFeed` edit functionality ([#7559](https://github.com/holoviz/panel/pull/7559))

## Version 1.5.5

This release fixes a regression causing .node_modules to be bundled into our released wheel and introduces a number of bug fixes and enhancements. Many thanks to @mayonnaisecolouredbenz7, @pmeier, @Italirz, @Coderambling and our maintainer team @MarcSkovMadsen, @hoxbro, @ahuang11, @thuydotm, @maximlt and @philippjfr.
Expand Down
6 changes: 6 additions & 0 deletions doc/about/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

See [the HoloViz blog](https://blog.holoviz.org/#category=panel) for a visual summary of the major features added in each release.

## Version 1.6.0

### Features

- Introduces `ChatMessage` and `ChatFeed` edit functionality ([#7559](https://github.com/holoviz/panel/pull/7559))

## Version 1.5.5

This release fixes a regression causing .node_modules to be bundled into our released wheel and introduces a number of bug fixes and enhancements. Many thanks to @mayonnaisecolouredbenz7, @pmeier, @Italirz, @Coderambling and our maintainer team @MarcSkovMadsen, @hoxbro, @ahuang11, @thuydotm, @maximlt and @philippjfr.
Expand Down
1 change: 1 addition & 0 deletions examples/reference/chat/ChatAreaInput.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"* **``enter_sends``** (bool): If True, pressing the Enter key sends the message, if False it is sent by pressing the Ctrl-Enter. Defaults to True.\n",
"* **``value``** (str): The value when the \"Enter\" or \"Ctrl-Enter\" key is pressed. Only to be used with `watch` or `bind` because the `value` resets to `\"\"` after the message is sent; use `value_input` instead to access what's currently available in the text input box.\n",
"* **``value_input``** (str): The current value updated on every key press.\n",
"* **`enter_pressed`** (bool): Event when the Enter/Ctrl+Enter key has been pressed.\n",
"\n",
"##### Display\n",
"\n",
Expand Down
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
41 changes: 39 additions & 2 deletions 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 @@ -159,7 +164,11 @@ class ChatFeed(ListPanel):
The text to display next to the placeholder icon.""")

placeholder_params = param.Dict(default={
"user": " ", "reaction_icons": {}, "show_copy_icon": False, "show_timestamp": False
"user": " ",
"reaction_icons": {},
"show_copy_icon": False,
"show_timestamp": False,
"show_edit_icon": False
}, doc="""
Params to pass to the placeholder ChatMessage, like `reaction_icons`,
`timestamp_format`, `show_avatar`, `show_user`, `show_timestamp`.
Expand Down Expand Up @@ -232,7 +241,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 +386,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 +425,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/Ctrl+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 and message_params.get("edit_callback"))
return super().stream(value, user=user, avatar=avatar, message=message, replace=replace, **message_params)
71 changes: 65 additions & 6 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,29 @@ 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(
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 +362,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 @@ -572,8 +597,9 @@ def _update_object_pane(self, event=None):
old = self._object_panel
self._object_panel = new = self._create_panel(self.object, old=old)
if old is not new:
self._center_row[0] = new
self._placeholder.update(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 +634,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
Loading
Loading