diff --git a/CHANGELOG.md b/CHANGELOG.md index 17acdb8283..db2b008a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/doc/about/releases.md b/doc/about/releases.md index 610549bae8..3d743ac030 100644 --- a/doc/about/releases.md +++ b/doc/about/releases.md @@ -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. diff --git a/examples/reference/chat/ChatAreaInput.ipynb b/examples/reference/chat/ChatAreaInput.ipynb index ca80644a21..474c3af301 100644 --- a/examples/reference/chat/ChatAreaInput.ipynb +++ b/examples/reference/chat/ChatAreaInput.ipynb @@ -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", diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index d754d02248..5ff0e6028e 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -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", @@ -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": {}, diff --git a/examples/reference/chat/ChatMessage.ipynb b/examples/reference/chat/ChatMessage.ipynb index 9315925a0e..60d9b6c7a1 100644 --- a/examples/reference/chat/ChatMessage.ipynb +++ b/examples/reference/chat/ChatMessage.ipynb @@ -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", @@ -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", diff --git a/panel/chat/feed.py b/panel/chat/feed.py index c299b948db..9384599ded 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -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.""") @@ -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`. @@ -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( @@ -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, @@ -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( diff --git a/panel/chat/input.py b/panel/chat/input.py index 5f885cc839..b7654aea77 100644 --- a/panel/chat/input.py +++ b/panel/chat/input.py @@ -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, } @@ -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 = "" diff --git a/panel/chat/interface.py b/panel/chat/interface.py index 8c8caf15c5..0acbe44188 100644 --- a/panel/chat/interface.py +++ b/panel/chat/interface.py @@ -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( @@ -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) diff --git a/panel/chat/message.py b/panel/chat/message.py index 395a70c81a..d2a5388469 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -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, ) @@ -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.""") @@ -241,6 +247,9 @@ 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 @@ -248,9 +257,6 @@ class ChatMessage(Pane): 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: @@ -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: @@ -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, @@ -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"], @@ -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): @@ -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. diff --git a/panel/dist/css/chat_message.css b/panel/dist/css/chat_message.css index 084785ec3a..0b7339fbe3 100644 --- a/panel/dist/css/chat_message.css +++ b/panel/dist/css/chat_message.css @@ -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; +} diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index ce3b20a217..921def4af4 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -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 diff --git a/panel/tests/chat/test_interface.py b/panel/tests/chat/test_interface.py index 86e2809b51..60132e6db9 100644 --- a/panel/tests/chat/test_interface.py +++ b/panel/tests/chat/test_interface.py @@ -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 diff --git a/panel/tests/chat/test_message.py b/panel/tests/chat/test_message.py index 4d56c81e3f..fe9ed98cee 100644 --- a/panel/tests/chat/test_message.py +++ b/panel/tests/chat/test_message.py @@ -51,10 +51,10 @@ def test_layout(self): assert isinstance(center_row, Row) object_pane = center_row[0] - assert isinstance(object_pane, Markdown) - assert object_pane.object == "ABC" + assert isinstance(object_pane.object, Markdown) + assert object_pane.object.object == "ABC" - icons = columns[1][4][1] + icons = columns[1][4][2] assert isinstance(icons, ChatReactionIcons) footer_col = columns[1][3] @@ -155,19 +155,19 @@ def test_update_user(self): def test_update_object(self): message = ChatMessage(object="Test") columns = message._composite.objects - object_pane = columns[1][2][0] + object_pane = columns[1][2][0].object assert isinstance(object_pane, Markdown) assert object_pane.object == "Test" message.object = TextInput(value="Also testing...") - object_pane = columns[1][2][0] + object_pane = columns[1][2][0].object assert isinstance(object_pane, TextInput) assert object_pane.value == "Also testing..." message.object = _FileInputMessage( contents=b"I am a file", file_name="test.txt", mime_type="text/plain" ) - object_pane = columns[1][2][0] + object_pane = columns[1][2][0].object assert isinstance(object_pane, Markdown) assert object_pane.object == "I am a file" diff --git a/panel/tests/ui/chat/test_chat_interface_ui.py b/panel/tests/ui/chat/test_chat_interface_ui.py index a694aead87..b601323be5 100644 --- a/panel/tests/ui/chat/test_chat_interface_ui.py +++ b/panel/tests/ui/chat/test_chat_interface_ui.py @@ -71,3 +71,31 @@ def test_chat_interface_show_button_tooltips(page): help_button.hover() expect(page.locator(".bk-Tooltip")).to_be_visible() + + +def test_chat_interface_edit_message(page): + def echo_callback(content, index, instance): + return content + + def edit_callback(content, index, instance): + instance.objects[index + 1].object = content + + chat_interface = ChatInterface(edit_callback=edit_callback, callback=echo_callback) + chat_interface.send("Edit this") + + serve_component(page, chat_interface) + + # find the edit icon and click .ti.ti-edit + # trict mode violation: locator(".ti-edit") resolved to 2 elements + page.locator(".ti-edit").first.click() + + # find the input field and type new message + chat_input = page.locator(".bk-input").first + chat_input.fill("Edited") + + # click enter + chat_input.press("Enter") + + expect(page.locator(".message").first).to_have_text("Edited") + for object in chat_interface.objects: + assert object.object == "Edited"