From 55b78d3b23d4ff15a50ddffca09a34b74a219a80 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Wed, 1 May 2024 10:29:18 -0700 Subject: [PATCH 1/4] Allow callbacks after append and stream --- panel/chat/feed.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 8c0147db9f..f85952a44b 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -78,6 +78,11 @@ class ChatFeed(ListPanel): >>> chat_feed.send("Hello World!", user="New User", avatar="😊") """ + append_callback = param.Callable(allow_refs=False, doc=""" + Callback to execute when a new message is added to the chat feed; + useful for logging or other side effects. Ignores the placeholder. + The signature must include the `instance` and `message` arguments.""") + auto_scroll_limit = param.Integer(default=200, bounds=(0, None), doc=""" Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 @@ -182,6 +187,8 @@ class ChatFeed(ListPanel): _callback_trigger = param.Event(doc="Triggers the callback to respond.") + _append_callback_trigger = param.Event(doc="Triggers the append callback.") + _disabled_stack = param.List(doc=""" The previous disabled state of the feed.""") @@ -262,6 +269,7 @@ def __init__(self, *objects, **params): # handle async callbacks using this trick self.param.watch(self._prepare_response, '_callback_trigger') + self.param.watch(self._prepare_after_append, '_append_callback_trigger') def _get_model( self, doc: Document, root: Model | None = None, @@ -510,6 +518,7 @@ async def _prepare_response(self, *_) -> None: await asyncio.gather( self._schedule_placeholder(future, num_entries), future, ) + self.param.trigger("_append_callback_trigger") except StopCallback: # callback was stopped by user self._callback_state = CallbackState.STOPPED @@ -580,6 +589,7 @@ def send( value = {"object": value} message = self._build_message(value, user=user, avatar=avatar) self.append(message) + self.param.trigger("_append_callback_trigger") if respond: self.respond() return message @@ -644,6 +654,7 @@ def stream( value = {"object": value} message = self._build_message(value, user=user, avatar=avatar) self._replace_placeholder(message) + self.param.trigger("_append_callback_trigger") return message def respond(self): @@ -758,6 +769,19 @@ def _serialize_for_transformers( serialized_messages.append({"role": role, "content": content}) return serialized_messages + async def _prepare_after_append(self): + """ + Trigger the append callback after a message is added to the chat feed. + """ + if self.append_callback is None: + return + + message = self._chat_log[-1] + if iscoroutinefunction(self.append_callback): + await self.append_callback(message) + else: + self.append_callback(message) + def serialize( self, exclude_users: List[str] | None = None, From 4d330ee43e19374a132836ea50321703e0de4f5f Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 2 May 2024 08:32:47 -0700 Subject: [PATCH 2/4] Fix and add docs --- examples/reference/chat/ChatFeed.ipynb | 1 + panel/chat/feed.py | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index decb3fd775..3a9c351963 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -48,6 +48,7 @@ "##### Other\n", "\n", "* **`header`** (Any): The header of the chat feed; commonly used for the title. Can be a string, pane, or widget.\n", + "* **`append_callback`** (callable): The callback to execute when a new message is *completely* added, i.e. generator exhausted, but the `stream` method will trigger this callback on every call. The signature must include the `message` and `instance` arguments.\n", "* **`callback_user`** (str): The default user name to use for the message provided by the callback.\n", "* **`callback_avatar`** (str | bytes | BytesIO | pn.pane.ImageBase): 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", "* **`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", diff --git a/panel/chat/feed.py b/panel/chat/feed.py index f85952a44b..3341cb27f8 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -79,14 +79,14 @@ class ChatFeed(ListPanel): """ append_callback = param.Callable(allow_refs=False, doc=""" - Callback to execute when a new message is added to the chat feed; - useful for logging or other side effects. Ignores the placeholder. - The signature must include the `instance` and `message` arguments.""") + The callback to execute when a new message is *completely* added, + i.e. generator exhausted, but the `stream` method will trigger this callback + on every call. The signature must include the `message` and `instance` arguments.""") auto_scroll_limit = param.Integer(default=200, bounds=(0, None), doc=""" Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 - disables auto-scrolling.""",) + disables auto-scrolling.""") callback = param.Callable(allow_refs=False, doc=""" Callback to execute when a user sends a message or @@ -769,7 +769,7 @@ def _serialize_for_transformers( serialized_messages.append({"role": role, "content": content}) return serialized_messages - async def _prepare_after_append(self): + async def _prepare_after_append(self, event): """ Trigger the append callback after a message is added to the chat feed. """ @@ -778,9 +778,9 @@ async def _prepare_after_append(self): message = self._chat_log[-1] if iscoroutinefunction(self.append_callback): - await self.append_callback(message) + await self.append_callback(message, self) else: - self.append_callback(message) + self.append_callback(message, self) def serialize( self, From 4093d43ff744fff3206f4b2dc2dd397609a9a5bd Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 2 May 2024 10:50:28 -0700 Subject: [PATCH 3/4] fixes and tests --- panel/chat/feed.py | 10 +++-- panel/tests/chat/test_feed.py | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 3341cb27f8..1cbbc986f8 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -269,7 +269,7 @@ def __init__(self, *objects, **params): # handle async callbacks using this trick self.param.watch(self._prepare_response, '_callback_trigger') - self.param.watch(self._prepare_after_append, '_append_callback_trigger') + self.param.watch(self._after_append_completed, '_append_callback_trigger') def _get_model( self, doc: Document, root: Model | None = None, @@ -438,6 +438,7 @@ async def _serialize_response(self, response: Any) -> ChatMessage | None: response_message = self._upsert_message(await response, response_message) else: response_message = self._upsert_message(response, response_message) + self.param.trigger("_append_callback_trigger") finally: if response_message: response_message.show_activity_dot = False @@ -492,6 +493,7 @@ async def _handle_callback(self, message, loop: asyncio.BaseEventLoop): else: response = await asyncio.to_thread(self.callback, *callback_args) await self._serialize_response(response) + return response async def _prepare_response(self, *_) -> None: """ @@ -518,7 +520,6 @@ async def _prepare_response(self, *_) -> None: await asyncio.gather( self._schedule_placeholder(future, num_entries), future, ) - self.param.trigger("_append_callback_trigger") except StopCallback: # callback was stopped by user self._callback_state = CallbackState.STOPPED @@ -654,6 +655,7 @@ def stream( value = {"object": value} message = self._build_message(value, user=user, avatar=avatar) self._replace_placeholder(message) + self.param.trigger("_append_callback_trigger") return message @@ -769,14 +771,14 @@ def _serialize_for_transformers( serialized_messages.append({"role": role, "content": content}) return serialized_messages - async def _prepare_after_append(self, event): + async def _after_append_completed(self, message): """ Trigger the append callback after a message is added to the chat feed. """ if self.append_callback is None: return - message = self._chat_log[-1] + message = self._chat_log.objects[-1] if iscoroutinefunction(self.append_callback): await self.append_callback(message, self) else: diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 6050976442..9aadfffd60 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -997,3 +997,85 @@ def test_invalid(self): chat_feed = ChatFeed() chat_feed.send("I'm a user", user="user") chat_feed.serialize(format="atransform") + + +@pytest.mark.xdist_group("chat") +class TestChatFeedAppendCallback: + + def test_return_string(self, chat_feed): + def callback(contents, user, instance): + yield f"Echo: {contents}" + + def append_callback(message, instance): + logs.append(message.object) + + logs = [] + chat_feed.callback = callback + chat_feed.append_callback = append_callback + chat_feed.send("Hello World!") + wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") + assert logs == ["Hello World!", "Echo: Hello World!"] + + def test_yield_string(self, chat_feed): + def callback(contents, user, instance): + yield f"Echo: {contents}" + + def append_callback(message, instance): + logs.append(message.object) + + logs = [] + chat_feed.callback = callback + chat_feed.append_callback = append_callback + chat_feed.send("Hello World!") + wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") + assert logs == ["Hello World!", "Echo: Hello World!"] + + def test_generator(self, chat_feed): + def callback(contents, user, instance): + message = "Echo: " + for char in contents: + message += char + yield message + + def append_callback(message, instance): + logs.append(message.object) + + logs = [] + chat_feed.callback = callback + chat_feed.append_callback = append_callback + chat_feed.send("Hello World!") + wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") + assert logs == ["Hello World!", "Echo: Hello World!"] + + def test_async_generator(self, chat_feed): + async def callback(contents, user, instance): + message = "Echo: " + for char in contents: + message += char + yield message + + async def append_callback(message, instance): + logs.append(message.object) + + logs = [] + chat_feed.callback = callback + chat_feed.append_callback = append_callback + chat_feed.send("Hello World!") + wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") + assert logs == ["Hello World!", "Echo: Hello World!"] + + def test_stream(self, chat_feed): + def callback(contents, user, instance): + message = instance.stream("Echo: ") + for char in contents: + message = instance.stream(char, message=message) + + def append_callback(message, instance): + logs.append(message.object) + + logs = [] + chat_feed.callback = callback + chat_feed.append_callback = append_callback + chat_feed.send("AB") + wait_until(lambda: chat_feed.objects[-1].object == "Echo: AB") + assert logs == ["AB", "Echo: ", "Echo: AB"] From 7690343d5925fc5513d6a5fc371f4901c70fb5ad Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 8 May 2024 12:21:57 +0200 Subject: [PATCH 4/4] Rename to post_hook --- examples/reference/chat/ChatFeed.ipynb | 2 +- panel/chat/feed.py | 39 +++++++++++++------------- panel/tests/chat/test_feed.py | 12 ++++---- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 3a9c351963..cf3fc7e2b3 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -48,13 +48,13 @@ "##### Other\n", "\n", "* **`header`** (Any): The header of the chat feed; commonly used for the title. Can be a string, pane, or widget.\n", - "* **`append_callback`** (callable): The callback to execute when a new message is *completely* added, i.e. generator exhausted, but the `stream` method will trigger this callback on every call. The signature must include the `message` and `instance` arguments.\n", "* **`callback_user`** (str): The default user name to use for the message provided by the callback.\n", "* **`callback_avatar`** (str | bytes | BytesIO | pn.pane.ImageBase): 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", "* **`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", "* **`placeholder_threshold`** (float): Min duration in seconds of buffering before displaying the placeholder. If 0, the placeholder will be disabled. Defaults to 0.2.\n", + "* **`post_hook`** (callable): A hook to execute after a new message is *completely* added, i.e. the generator is exhausted. The `stream` method will trigger this callback on every call. The signature must include the `message` and `instance` arguments.\n", "* **`auto_scroll_limit`** (int): Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 disables auto-scrolling.\n", "* **`scroll_button_threshold`** (int): Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 disables the scroll button.\n", "* **`load_buffer`** (int): The number of objects loaded on each side of the visible objects. When scrolled halfway into the buffer, the feed will automatically load additional objects while unloading objects on the opposite side.\n", diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 1cbbc986f8..f458100b02 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -78,11 +78,6 @@ class ChatFeed(ListPanel): >>> chat_feed.send("Hello World!", user="New User", avatar="😊") """ - append_callback = param.Callable(allow_refs=False, doc=""" - The callback to execute when a new message is *completely* added, - i.e. generator exhausted, but the `stream` method will trigger this callback - on every call. The signature must include the `message` and `instance` arguments.""") - auto_scroll_limit = param.Integer(default=200, bounds=(0, None), doc=""" Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 @@ -138,6 +133,11 @@ class ChatFeed(ListPanel): `help` as the user. This is useful for providing instructions, and will not be included in the `serialize` method by default.""") + load_buffer = param.Integer(default=50, bounds=(0, None), doc=""" + The number of objects loaded on each side of the visible objects. + When scrolled halfway into the buffer, the feed will automatically + load additional objects while unloading objects on the opposite side.""") + placeholder_text = param.String(default="", doc=""" The text to display next to the placeholder icon.""") @@ -153,6 +153,12 @@ class ChatFeed(ListPanel): Min duration in seconds of buffering before displaying the placeholder. If 0, the placeholder will be disabled.""") + post_hook = param.Callable(allow_refs=False, doc=""" + A hook to execute after a new message is *completely* added, + i.e. the generator is exhausted. The `stream` method will trigger + this callback on every call. The signature must include the + `message` and `instance` arguments.""") + renderers = param.HookList(doc=""" A callable or list of callables that accept the value and return a Panel object to render the value. If a list is provided, will @@ -160,11 +166,6 @@ class ChatFeed(ListPanel): exception. If None, will attempt to infer the renderer from the value.""") - load_buffer = param.Integer(default=50, bounds=(0, None), doc=""" - The number of objects loaded on each side of the visible objects. - When scrolled halfway into the buffer, the feed will automatically - load additional objects while unloading objects on the opposite side.""") - scroll_button_threshold = param.Integer(default=100, bounds=(0, None),doc=""" Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 @@ -187,7 +188,7 @@ class ChatFeed(ListPanel): _callback_trigger = param.Event(doc="Triggers the callback to respond.") - _append_callback_trigger = param.Event(doc="Triggers the append callback.") + _post_hook_trigger = param.Event(doc="Triggers the append callback.") _disabled_stack = param.List(doc=""" The previous disabled state of the feed.""") @@ -269,7 +270,7 @@ def __init__(self, *objects, **params): # handle async callbacks using this trick self.param.watch(self._prepare_response, '_callback_trigger') - self.param.watch(self._after_append_completed, '_append_callback_trigger') + self.param.watch(self._after_append_completed, '_post_hook_trigger') def _get_model( self, doc: Document, root: Model | None = None, @@ -438,7 +439,7 @@ async def _serialize_response(self, response: Any) -> ChatMessage | None: response_message = self._upsert_message(await response, response_message) else: response_message = self._upsert_message(response, response_message) - self.param.trigger("_append_callback_trigger") + self.param.trigger("_post_hook_trigger") finally: if response_message: response_message.show_activity_dot = False @@ -590,7 +591,7 @@ def send( value = {"object": value} message = self._build_message(value, user=user, avatar=avatar) self.append(message) - self.param.trigger("_append_callback_trigger") + self.param.trigger("_post_hook_trigger") if respond: self.respond() return message @@ -656,7 +657,7 @@ def stream( message = self._build_message(value, user=user, avatar=avatar) self._replace_placeholder(message) - self.param.trigger("_append_callback_trigger") + self.param.trigger("_post_hook_trigger") return message def respond(self): @@ -775,14 +776,14 @@ async def _after_append_completed(self, message): """ Trigger the append callback after a message is added to the chat feed. """ - if self.append_callback is None: + if self.post_hook is None: return message = self._chat_log.objects[-1] - if iscoroutinefunction(self.append_callback): - await self.append_callback(message, self) + if iscoroutinefunction(self.post_hook): + await self.post_hook(message, self) else: - self.append_callback(message, self) + self.post_hook(message, self) def serialize( self, diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 9aadfffd60..3d7207da2f 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -1000,7 +1000,7 @@ def test_invalid(self): @pytest.mark.xdist_group("chat") -class TestChatFeedAppendCallback: +class TestChatFeedPostHook: def test_return_string(self, chat_feed): def callback(contents, user, instance): @@ -1011,7 +1011,7 @@ def append_callback(message, instance): logs = [] chat_feed.callback = callback - chat_feed.append_callback = append_callback + chat_feed.post_hook = append_callback chat_feed.send("Hello World!") wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") assert logs == ["Hello World!", "Echo: Hello World!"] @@ -1025,7 +1025,7 @@ def append_callback(message, instance): logs = [] chat_feed.callback = callback - chat_feed.append_callback = append_callback + chat_feed.post_hook = append_callback chat_feed.send("Hello World!") wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") assert logs == ["Hello World!", "Echo: Hello World!"] @@ -1042,7 +1042,7 @@ def append_callback(message, instance): logs = [] chat_feed.callback = callback - chat_feed.append_callback = append_callback + chat_feed.post_hook = append_callback chat_feed.send("Hello World!") wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") assert logs == ["Hello World!", "Echo: Hello World!"] @@ -1059,7 +1059,7 @@ async def append_callback(message, instance): logs = [] chat_feed.callback = callback - chat_feed.append_callback = append_callback + chat_feed.post_hook = append_callback chat_feed.send("Hello World!") wait_until(lambda: chat_feed.objects[-1].object == "Echo: Hello World!") assert logs == ["Hello World!", "Echo: Hello World!"] @@ -1075,7 +1075,7 @@ def append_callback(message, instance): logs = [] chat_feed.callback = callback - chat_feed.append_callback = append_callback + chat_feed.post_hook = append_callback chat_feed.send("AB") wait_until(lambda: chat_feed.objects[-1].object == "Echo: AB") assert logs == ["AB", "Echo: ", "Echo: AB"]