diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb80f5d35..2b6f82a129 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 ec6bf67459..78263c025f 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 3e6f811417..3903f7cb76 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 d5bda8cabe..3a646ddd88 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 + (isinstance(user, str) 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 b41bd1626b..a492d7d567 100644 --- a/panel/chat/input.py +++ b/panel/chat/input.py @@ -71,6 +71,9 @@ class ChatAreaInput(_PnTextAreaInput): Can only be set during initialization.""", ) + enter_pressed = param.Event(doc=""" + Event when the Enter/Ctrl+Enter key has been pressed.""") + max_length = param.Integer(default=50000, doc=""" Max count of characters in the input field.""") @@ -78,6 +81,7 @@ class ChatAreaInput(_PnTextAreaInput): _rename: ClassVar[Mapping[str, str | None]] = { "value": None, + "enter_pressed": None, **_PnTextAreaInput._rename, } @@ -102,5 +106,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 432d3ed6b8..aac3cc43d0 100644 --- a/panel/chat/interface.py +++ b/panel/chat/interface.py @@ -692,6 +692,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( @@ -738,4 +739,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..a6024f4910 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, ) @@ -182,6 +185,9 @@ class ChatMessage(Pane): to use when the user is specified but the avatar is. You can modify, but not replace the dictionary.""") + edited = param.Event(doc=""" + An event that is triggered when the message is edited.""") + footer_objects = param.List(doc=""" A list of objects to display in the column of the footer of the message.""") @@ -215,6 +221,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.""") @@ -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 7727019bd5..c36a41f791 100644 --- a/panel/dist/css/chat_message.css +++ b/panel/dist/css/chat_message.css @@ -148,6 +148,21 @@ 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; +} + pre { white-space: pre-wrap; word-wrap: break-word; diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 1ac906b1e5..94a868ae87 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -1581,3 +1581,22 @@ def append_callback(message, instance): chat_feed.send("AB") await async_wait_until(lambda: chat_feed.objects[-1].object == "Echo: AB") await async_wait_until(lambda: logs == ["AB", "Echo: ", "Echo: AB"]) + + +@pytest.mark.xdist_group("chat") +class TestChatFeedEditCallback: + + @pytest.mark.parametrize("edit_callback", [None, lambda content, index, instance: ""]) + async 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"]) + async 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 9cf9b64412..3f9ebf1ef3 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() + + async 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"]) + async 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 95887b064e..f2db122208 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"