From 782a1303e956de9af68926aaafa0e41f47acd030 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 29 Mar 2024 18:44:31 -0700 Subject: [PATCH 01/30] Start adding chat steps --- panel/chat/__init__.py | 8 +- panel/chat/message.py | 62 ++------------- panel/chat/steps.py | 140 ++++++++++++++++++++++++++++++++++ panel/chat/utils.py | 90 ++++++++++++++++++++++ panel/dist/css/chat_steps.css | 23 ++++++ 5 files changed, 267 insertions(+), 56 deletions(-) create mode 100644 panel/chat/steps.py create mode 100644 panel/chat/utils.py create mode 100644 panel/dist/css/chat_steps.css diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index fe4a2a2921..ab5840f78f 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -28,6 +28,7 @@ For more detail see the Reference Gallery guide. https://panel.holoviz.org/reference/chat/ChatInterface.html """ + import importlib as _importlib from .feed import ChatFeed # noqa @@ -35,21 +36,24 @@ from .input import ChatAreaInput # noqa from .interface import ChatInterface # noqa from .message import ChatMessage # noqa +from .steps import ChatSteps # noqa def __getattr__(name): """ Lazily import langchain module when accessed. """ - if name == 'langchain': - return _importlib.import_module('panel.chat.langchain') + if name == "langchain": + return _importlib.import_module("panel.chat.langchain") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = ( "ChatAreaInput", "ChatFeed", "ChatInterface", "ChatMessage", "ChatReactionIcons", + "ChatSteps", "langchain", ) diff --git a/panel/chat/message.py b/panel/chat/message.py index 7570e1bf2e..18c433bdca 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -5,7 +5,6 @@ from __future__ import annotations import datetime -import re from contextlib import ExitStack from dataclasses import dataclass @@ -35,6 +34,7 @@ from ..viewable import Viewable from ..widgets.base import Widget from .icon import ChatCopyIcon, ChatReactionIcons +from .utils import avatar_lookup, stream_to if TYPE_CHECKING: from bokeh.document import Document @@ -423,31 +423,6 @@ def _get_model( self._models[ref] = (model, parent) return model - @staticmethod - def _to_alpha_numeric(user: str) -> str: - """ - Convert the user name to an alpha numeric string, - removing all non-alphanumeric characters. - """ - return re.sub(r"\W+", "", user).lower() - - def _avatar_lookup(self, user: str) -> Avatar: - """ - Lookup the avatar for the user. - """ - alpha_numeric_key = self._to_alpha_numeric(user) - # always use the default first - updated_avatars = DEFAULT_AVATARS.copy() - # update with the user input - updated_avatars.update(self.default_avatars) - # correct the keys to be alpha numeric - updated_avatars = { - self._to_alpha_numeric(key): value for key, value in updated_avatars.items() - } - - # now lookup the avatar - return updated_avatars.get(alpha_numeric_key, self.avatar).format(dist_path=CDN_DIST) - def _select_renderer( self, contents: Any, @@ -666,7 +641,12 @@ def _update_avatar(self): if self.avatar_lookup: self.avatar = self.avatar_lookup(self.user) else: - self.avatar = self._avatar_lookup(self.user) + self.avatar = avatar_lookup( + self.user, + self.avatar, + self.default_avatars, + DEFAULT_AVATARS, + ) def _update_chat_copy_icon(self): object_panel = self._object_panel @@ -703,33 +683,7 @@ def stream(self, token: str, replace: bool = False): replace: bool (default=False) Whether to replace the existing text. """ - i = -1 - parent_panel = None - object_panel = self - attr = "object" - obj = self.object - if obj is None: - obj = "" - - while not isinstance(obj, str) or isinstance(object_panel, ImageBase): - object_panel = obj - if hasattr(obj, "objects"): - parent_panel = obj - attr = "objects" - obj = obj.objects[i] - i = -1 - elif hasattr(obj, "object"): - attr = "object" - obj = obj.object - elif hasattr(obj, "value"): - attr = "value" - obj = obj.value - elif parent_panel is not None: - obj = parent_panel - parent_panel = None - i -= 1 - contents = token if replace else obj + token - setattr(object_panel, attr, contents) + stream_to(obj=self.object, token=token, replace=replace) def update( self, diff --git a/panel/chat/steps.py b/panel/chat/steps.py new file mode 100644 index 0000000000..8ff64975c3 --- /dev/null +++ b/panel/chat/steps.py @@ -0,0 +1,140 @@ +from contextlib import contextmanager +from io import BytesIO +from typing import ClassVar, List, Mapping + +import param + +from ..io.resources import CDN_DIST +from ..layout import Card, Row +from ..pane import Markdown +from ..pane.image import Image +from ..pane.markup import HTML +from ..viewable import Viewable +from .feed import ChatFeed +from .interface import ChatInterface +from .utils import avatar_lookup, stream_to + +DEFAULT_STATUS_AVATARS = { + "pending": "⏳", + "completed": "✅", + "failed": "❌", +} + + +class ChatSteps(Card): + + status = param.Selector( + default="pending", objects=["pending", "completed", "failed"] + ) + + pending_title = param.String( + default="Loading...", + doc="Title to display when status is pending", + ) + + completed_title = param.String( + doc="Title to display when status is completed; if not provided, uses the last object.", + ) + + default_avatars = param.Dict( + default=DEFAULT_STATUS_AVATARS, + doc="Mapping from status to default status avatar", + ) + + margin = param.Parameter( + default=(5, 5, 5, 10), + doc=""" + Allows to create additional space around the component. May + be specified as a two-tuple of the form (vertical, horizontal) + or a four-tuple (top, right, bottom, left).""", + ) + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_steps.css"] + + _rename: ClassVar[Mapping[str, str | None]] = { + "default_avatars": None, + "pending_title": None, + "completed_title": None, + "status": None, + **Card._rename, + } + + def __init__(self, **params): + self._avatar_container = Row( + align="center" + ) # placeholder for avatar; weird alignment issue without + super().__init__(**params) + + self.header = Row( + self._avatar_container, + HTML( + self.param.title, + margin=0, + css_classes=["steps-title"], + ), + stylesheets=self._stylesheets + self.param.stylesheets.rx(), + ) + + @param.depends("status", watch=True, on_init=True) + def _render_avatar(self): + """ + Render the avatar pane as some HTML text or Image pane. + """ + avatar = avatar_lookup( + self.status, + None, + self.default_avatars, + DEFAULT_STATUS_AVATARS, + ) + + if not avatar and self.user: + avatar = self.user[0] + + avatar_params = { + "css_classes": ["status-avatar"], + "margin": (0, 5, 0, 0), + "width": 15, + "height": 15, + } + if isinstance(avatar, Viewable): + avatar_pane = avatar + avatar_params["css_classes"] = ( + avatar_params.get("css_classes", []) + avatar_pane.css_classes + ) + avatar_pane.param.update(avatar_params) + elif not isinstance(avatar, (BytesIO, bytes)) and len(avatar) == 1: + # single character + avatar_pane = HTML(avatar, **avatar_params) + else: + try: + avatar_pane = Image(avatar, **avatar_params) + except ValueError: + # likely an emoji + avatar_pane = HTML(avatar, **avatar_params) + self._avatar_container.objects = [avatar_pane] + + @contextmanager + def pending(self, instance: ChatFeed | ChatInterface): + instance.stream(self) + self.title = self.pending_title + yield self + self.status = "completed" + self.title = ( + self.completed_title if self.completed_title or not self.objects + else self.objects[-1].object + ) + + def stream_title(self, token, replace=False): + if replace: + self.title = token + else: + self.title += token + return self.title + + def stream(self, token, replace=False, message=None): + if message is None: + message = Markdown(token, margin=(0, 0), css_classes=["steps-message"]) + self.append(message) + else: + message = stream_to(message, token, replace=replace) + return message diff --git a/panel/chat/utils.py b/panel/chat/utils.py new file mode 100644 index 0000000000..0780a2a3aa --- /dev/null +++ b/panel/chat/utils.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import re + +from io import BytesIO +from typing import Any, Dict, Union + +from ..io.resources import CDN_DIST +from ..pane.image import ImageBase + +Avatar = Union[str, BytesIO, bytes, ImageBase] +AvatarDict = Dict[str, Avatar] + + +def to_alpha_numeric(user: str) -> str: + """ + Convert the user name to an alpha numeric string, + removing all non-alphanumeric characters. + """ + return re.sub(r"\W+", "", user).lower() + + +def avatar_lookup( + user: str, + avatar: Any, + avatars: Dict[str, Any], + default_avatars: Dict[str, Any], +) -> Avatar: + """ + Lookup the avatar for the user. + """ + alpha_numeric_key = to_alpha_numeric(user) + # always use the default first + updated_avatars = default_avatars.copy() + # update with the user input + updated_avatars.update(avatars) + # correct the keys to be alpha numeric + updated_avatars = { + to_alpha_numeric(key): value for key, value in updated_avatars.items() + } + + # now lookup the avatar + avatar = updated_avatars.get(alpha_numeric_key, avatar) + if isinstance(avatar, str): + avatar = avatar.format(dist_path=CDN_DIST) + return avatar + + +def stream_to(obj, token, replace=False): + """ + Updates the message with the new token traversing the object to + allow updating nested objects. When traversing a nested Panel + the last object that supports rendering strings is updated, e.g. + in a layout of `Column(Markdown(...), Image(...))` the Markdown + pane is updated. + + Arguments + --------- + token: str + The token to stream to the text pane. + replace: bool (default=False) + Whether to replace the existing text. + """ + i = -1 + parent_panel = None + object_panel = None + attr = "object" + if obj is None: + obj = "" + + while not isinstance(obj, str) or isinstance(object_panel, ImageBase): + object_panel = obj + if hasattr(obj, "objects"): + parent_panel = obj + attr = "objects" + obj = obj.objects[i] + i = -1 + elif hasattr(obj, "object"): + attr = "object" + obj = obj.object + elif hasattr(obj, "value"): + attr = "value" + obj = obj.value + elif parent_panel is not None: + obj = parent_panel + parent_panel = None + i -= 1 + contents = token if replace else obj + token + setattr(object_panel, attr, contents) + return object_panel diff --git a/panel/dist/css/chat_steps.css b/panel/dist/css/chat_steps.css new file mode 100644 index 0000000000..9362886981 --- /dev/null +++ b/panel/dist/css/chat_steps.css @@ -0,0 +1,23 @@ +/* inherit from chat_message */ + +:host(.card) { + outline: 0; + box-shadow: + color-mix(in srgb, var(--panel-shadow-color) 30%, transparent) 0px 1px 2px + 0px, + color-mix(in srgb, var(--panel-shadow-color) 15%, transparent) 0px 1px 3px + 1px; +} + +:host(.card) .card-header { + border-bottom: 0; + background-color: var(--panel-surface-color, #f1f1f1); +} + +.steps-title { + font-size: 1.25em; +} + +.steps-message { + font-size: 1em; +} From ae589ca6a8b84b1ed6f3495dbbff3014fd6c56f7 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 1 Apr 2024 14:15:04 -0700 Subject: [PATCH 02/30] Fix CSS --- panel/dist/css/chat_steps.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/panel/dist/css/chat_steps.css b/panel/dist/css/chat_steps.css index 9362886981..1bf650f1ca 100644 --- a/panel/dist/css/chat_steps.css +++ b/panel/dist/css/chat_steps.css @@ -7,11 +7,13 @@ 0px, color-mix(in srgb, var(--panel-shadow-color) 15%, transparent) 0px 1px 3px 1px; + background-color: var(--panel-surface-color, #f1f1f1); + margin-top: 0px; } :host(.card) .card-header { border-bottom: 0; - background-color: var(--panel-surface-color, #f1f1f1); + padding-block: 15px; } .steps-title { @@ -19,5 +21,7 @@ } .steps-message { - font-size: 1em; + width: 100%; + font-size: 1.25em; + border-bottom: 1px solid var(--panel-border-color, #e0e0e0); } From 28a4eedc9bef0bf0b274a4af54072c7387f41a5f Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 1 Apr 2024 18:09:06 -0700 Subject: [PATCH 03/30] Add chat steps --- panel/chat/__init__.py | 3 +- panel/chat/feed.py | 11 ++++ panel/chat/{steps.py => step.py} | 108 +++++++++++++++++++++---------- panel/dist/css/chat_step.css | 16 +++++ panel/dist/css/chat_steps.css | 20 ++---- 5 files changed, 107 insertions(+), 51 deletions(-) rename panel/chat/{steps.py => step.py} (62%) create mode 100644 panel/dist/css/chat_step.css diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index ab5840f78f..eab3f59e2a 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -36,7 +36,7 @@ from .input import ChatAreaInput # noqa from .interface import ChatInterface # noqa from .message import ChatMessage # noqa -from .steps import ChatSteps # noqa +from .step import ChatStep, ChatSteps # noqa def __getattr__(name): @@ -54,6 +54,7 @@ def __getattr__(name): "ChatInterface", "ChatMessage", "ChatReactionIcons", + "ChatStep", "ChatSteps", "langchain", ) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 9479c624e6..371b85e7c5 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -28,6 +28,7 @@ from ..pane.image import SVG from .icon import ChatReactionIcons from .message import ChatMessage +from .step import ChatStep, ChatSteps if TYPE_CHECKING: from bokeh.document import Document @@ -891,3 +892,13 @@ def select(self, selector=None): (callable(selector) and not isinstance(selector, type) and selector(self))): selected.append(self) return selected + self._card.select(selector) + + def step(self, **kwargs): + chat_step = ChatStep(**kwargs) + self.stream(chat_step) + return chat_step + + def steps(self, **kwargs): + chat_steps = ChatSteps(**kwargs) + self.stream(chat_steps) + return chat_steps diff --git a/panel/chat/steps.py b/panel/chat/step.py similarity index 62% rename from panel/chat/steps.py rename to panel/chat/step.py index 8ff64975c3..c76b73c081 100644 --- a/panel/chat/steps.py +++ b/panel/chat/step.py @@ -1,4 +1,3 @@ -from contextlib import contextmanager from io import BytesIO from typing import ClassVar, List, Mapping @@ -10,26 +9,20 @@ from ..pane.image import Image from ..pane.markup import HTML from ..viewable import Viewable -from .feed import ChatFeed -from .interface import ChatInterface from .utils import avatar_lookup, stream_to DEFAULT_STATUS_AVATARS = { "pending": "⏳", + "running": "🏃", "completed": "✅", "failed": "❌", } -class ChatSteps(Card): +class _ChatStepBase(Card): status = param.Selector( - default="pending", objects=["pending", "completed", "failed"] - ) - - pending_title = param.String( - default="Loading...", - doc="Title to display when status is pending", + default="pending", objects=["pending", "running", "completed", "failed"] ) completed_title = param.String( @@ -49,33 +42,33 @@ class ChatSteps(Card): or a four-tuple (top, right, bottom, left).""", ) - _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_steps.css"] - _rename: ClassVar[Mapping[str, str | None]] = { "default_avatars": None, - "pending_title": None, "completed_title": None, "status": None, **Card._rename, } def __init__(self, **params): + self._instance = None self._avatar_container = Row( - align="center" + align="center", css_classes=["step-avatar-container"] ) # placeholder for avatar; weird alignment issue without super().__init__(**params) - + self._render_avatar() + self._title_pane = HTML( + self.param.title, + margin=0, + css_classes=["step-title"], + ) self.header = Row( self._avatar_container, - HTML( - self.param.title, - margin=0, - css_classes=["steps-title"], - ), + self._title_pane, stylesheets=self._stylesheets + self.param.stylesheets.rx(), + css_classes=["step-header"], ) - @param.depends("status", watch=True, on_init=True) + @param.depends("status", watch=True) def _render_avatar(self): """ Render the avatar pane as some HTML text or Image pane. @@ -91,7 +84,7 @@ def _render_avatar(self): avatar = self.user[0] avatar_params = { - "css_classes": ["status-avatar"], + "css_classes": ["step-avatar"], "margin": (0, 5, 0, 0), "width": 15, "height": 15, @@ -113,17 +106,6 @@ def _render_avatar(self): avatar_pane = HTML(avatar, **avatar_params) self._avatar_container.objects = [avatar_pane] - @contextmanager - def pending(self, instance: ChatFeed | ChatInterface): - instance.stream(self) - self.title = self.pending_title - yield self - self.status = "completed" - self.title = ( - self.completed_title if self.completed_title or not self.objects - else self.objects[-1].object - ) - def stream_title(self, token, replace=False): if replace: self.title = token @@ -131,10 +113,68 @@ def stream_title(self, token, replace=False): self.title += token return self.title + def __enter__(self): + self.status = "running" + if not self.title: + self.title = "Running..." + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.status = "completed" + if self.completed_title: + self.title = self.completed_title + elif self.objects: + obj = self.objects[-1] + for _ in range(100): + if hasattr(obj, "objects"): + obj = obj.objects[-1] + else: + break + + if hasattr(obj, "object"): + obj = obj.object + + if isinstance(obj, str): + self.title = obj + else: + self.title = "Completed!" + + +class ChatSteps(_ChatStepBase): + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_steps.css"] + + def step(self, **kwargs): + step_ = ChatStep(margin=0, **kwargs) + self.append(step_) + return step_ + + def serialize(self): + ... + + +class ChatStep(_ChatStepBase): + + completed_title = param.String( + default="Completed!", + doc="Title to display when status is completed; if not provided, uses the last object.", + ) + + collapsed = param.Boolean( + default=True, + doc=""" + Whether the contents of the Card are collapsed.""", + ) + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_step.css"] + def stream(self, token, replace=False, message=None): if message is None: - message = Markdown(token, margin=(0, 0), css_classes=["steps-message"]) + message = Markdown(token, css_classes=["step-message"]) self.append(message) else: message = stream_to(message, token, replace=replace) return message + + def serialize(self): + ... diff --git a/panel/dist/css/chat_step.css b/panel/dist/css/chat_step.css new file mode 100644 index 0000000000..5c134361c9 --- /dev/null +++ b/panel/dist/css/chat_step.css @@ -0,0 +1,16 @@ +/* inherit from chat_message */ + +:host(.card) { + outline: 0; + box-shadow: none; + background-color: var(--panel-surface-color, #f1f1f1); + margin-top: 0px; + width: fit-content; + max-width: fit-content; + min-width: 200px; +} + +:host(.card) .card-header { + border-bottom: 1px solid var(--panel-border-color, #e0e0e0); + padding-block: 5px; +} diff --git a/panel/dist/css/chat_steps.css b/panel/dist/css/chat_steps.css index 1bf650f1ca..00488fe2cb 100644 --- a/panel/dist/css/chat_steps.css +++ b/panel/dist/css/chat_steps.css @@ -1,27 +1,15 @@ -/* inherit from chat_message */ - -:host(.card) { - outline: 0; - box-shadow: - color-mix(in srgb, var(--panel-shadow-color) 30%, transparent) 0px 1px 2px - 0px, - color-mix(in srgb, var(--panel-shadow-color) 15%, transparent) 0px 1px 3px - 1px; - background-color: var(--panel-surface-color, #f1f1f1); - margin-top: 0px; -} +@import url('chat_step.css'); :host(.card) .card-header { - border-bottom: 0; + border-bottom: 1px solid var(--panel-border-color, #e0e0e0); padding-block: 15px; } -.steps-title { +.step-title { font-size: 1.25em; } -.steps-message { +.step-message { width: 100%; font-size: 1.25em; - border-bottom: 1px solid var(--panel-border-color, #e0e0e0); } From a56872d32f811f7ec4b1ab54f964d941a2f3f139 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Tue, 2 Apr 2024 16:20:49 -0700 Subject: [PATCH 04/30] Address comments --- panel/chat/feed.py | 14 +++++++++---- panel/chat/step.py | 39 ++++++++++++++++++++---------------- panel/dist/css/chat_step.css | 4 +--- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 371b85e7c5..d539614ca2 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -893,12 +893,18 @@ def select(self, selector=None): selected.append(self) return selected + self._card.select(selector) - def step(self, **kwargs): - chat_step = ChatStep(**kwargs) + def step(self, chat_step: ChatStep | None = None, **kwargs): + if chat_step is None: + chat_step = ChatStep(**kwargs) + for kwarg in kwargs: + setattr(chat_step, kwarg, kwargs[kwarg]) self.stream(chat_step) return chat_step - def steps(self, **kwargs): - chat_steps = ChatSteps(**kwargs) + def steps(self, chat_steps: ChatSteps | None = None, **kwargs): + if chat_steps is None: + chat_steps = ChatSteps(**kwargs) + for kwarg in kwargs: + setattr(chat_steps, kwarg, kwargs[kwarg]) self.stream(chat_steps) return chat_steps diff --git a/panel/chat/step.py b/panel/chat/step.py index c76b73c081..8745ae2e83 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -9,13 +9,14 @@ from ..pane.image import Image from ..pane.markup import HTML from ..viewable import Viewable +from ..widgets.indicators import BooleanStatus, LoadingSpinner from .utils import avatar_lookup, stream_to DEFAULT_STATUS_AVATARS = { - "pending": "⏳", - "running": "🏃", - "completed": "✅", - "failed": "❌", + "pending": BooleanStatus(value=False, margin=0, color="warning"), + "running": LoadingSpinner(value=True, margin=0), + "completed":BooleanStatus(value=True, margin=0, color="success"), + "failed": BooleanStatus(value=False, margin=0, color="danger") } @@ -138,19 +139,7 @@ def __exit__(self, exc_type, exc_value, traceback): self.title = obj else: self.title = "Completed!" - - -class ChatSteps(_ChatStepBase): - - _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_steps.css"] - - def step(self, **kwargs): - step_ = ChatStep(margin=0, **kwargs) - self.append(step_) - return step_ - - def serialize(self): - ... + self.collapsed = True class ChatStep(_ChatStepBase): @@ -178,3 +167,19 @@ def stream(self, token, replace=False, message=None): def serialize(self): ... + + +class ChatSteps(_ChatStepBase): + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_steps.css"] + + def step(self, chat_step: ChatStep | None = None, **kwargs): + if chat_step is None: + chat_step = ChatStep(margin=0, **kwargs) + for kwarg in kwargs: + setattr(chat_step, kwarg, kwargs[kwarg]) + self.append(chat_step) + return chat_step + + def serialize(self): + ... diff --git a/panel/dist/css/chat_step.css b/panel/dist/css/chat_step.css index 5c134361c9..1fa54b6f0c 100644 --- a/panel/dist/css/chat_step.css +++ b/panel/dist/css/chat_step.css @@ -5,9 +5,7 @@ box-shadow: none; background-color: var(--panel-surface-color, #f1f1f1); margin-top: 0px; - width: fit-content; - max-width: fit-content; - min-width: 200px; + width: 100%; } :host(.card) .card-header { From d35538b66c78700e24701b794856c642c04bba73 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Tue, 14 May 2024 19:24:52 -0700 Subject: [PATCH 05/30] redesign layout --- panel/chat/__init__.py | 1 - panel/chat/feed.py | 22 ++--- panel/chat/step.py | 143 +++++++++++++++++--------------- panel/dist/css/chat_message.css | 1 + panel/dist/css/chat_step.css | 30 ++++++- panel/dist/css/chat_steps.css | 17 +--- 6 files changed, 110 insertions(+), 104 deletions(-) diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index eab3f59e2a..7e48a71ce1 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -55,6 +55,5 @@ def __getattr__(name): "ChatMessage", "ChatReactionIcons", "ChatStep", - "ChatSteps", "langchain", ) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index d539614ca2..020eb31f97 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -28,7 +28,7 @@ from ..pane.image import SVG from .icon import ChatReactionIcons from .message import ChatMessage -from .step import ChatStep, ChatSteps +from .step import ChatSteps if TYPE_CHECKING: from bokeh.document import Document @@ -198,6 +198,7 @@ class ChatFeed(ListPanel): _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_feed.css"] def __init__(self, *objects, **params): + self._steps = None self._callback_future = None if params.get("renderers") and not isinstance(params["renderers"], list): @@ -893,18 +894,7 @@ def select(self, selector=None): selected.append(self) return selected + self._card.select(selector) - def step(self, chat_step: ChatStep | None = None, **kwargs): - if chat_step is None: - chat_step = ChatStep(**kwargs) - for kwarg in kwargs: - setattr(chat_step, kwarg, kwargs[kwarg]) - self.stream(chat_step) - return chat_step - - def steps(self, chat_steps: ChatSteps | None = None, **kwargs): - if chat_steps is None: - chat_steps = ChatSteps(**kwargs) - for kwarg in kwargs: - setattr(chat_steps, kwarg, kwargs[kwarg]) - self.stream(chat_steps) - return chat_steps + def steps(self): + steps = ChatSteps() + self.send(steps, respond=False) + return steps diff --git a/panel/chat/step.py b/panel/chat/step.py index 8745ae2e83..d4a627766c 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -1,13 +1,13 @@ from io import BytesIO -from typing import ClassVar, List, Mapping +from typing import ClassVar, Mapping import param from ..io.resources import CDN_DIST -from ..layout import Card, Row -from ..pane import Markdown +from ..layout import Card, Column, Row from ..pane.image import Image -from ..pane.markup import HTML +from ..pane.markup import HTML, Markdown +from ..pane.placeholder import Placeholder from ..viewable import Viewable from ..widgets.indicators import BooleanStatus, LoadingSpinner from .utils import avatar_lookup, stream_to @@ -15,21 +15,28 @@ DEFAULT_STATUS_AVATARS = { "pending": BooleanStatus(value=False, margin=0, color="warning"), "running": LoadingSpinner(value=True, margin=0), - "completed":BooleanStatus(value=True, margin=0, color="success"), - "failed": BooleanStatus(value=False, margin=0, color="danger") + "completed": BooleanStatus(value=True, margin=0, color="success"), + "failed": BooleanStatus(value=False, margin=0, color="danger"), } -class _ChatStepBase(Card): +class ChatStep(Card): status = param.Selector( default="pending", objects=["pending", "running", "completed", "failed"] ) completed_title = param.String( + default="Completed!", doc="Title to display when status is completed; if not provided, uses the last object.", ) + collapsed = param.Boolean( + default=False, + doc=""" + Whether the contents of the Card are collapsed.""", + ) + default_avatars = param.Dict( default=DEFAULT_STATUS_AVATARS, doc="Mapping from status to default status avatar", @@ -50,11 +57,13 @@ class _ChatStepBase(Card): **Card._rename, } + _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_step.css"] + def __init__(self, **params): self._instance = None - self._avatar_container = Row( - align="center", css_classes=["step-avatar-container"] - ) # placeholder for avatar; weird alignment issue without + self._avatar_placeholder = Placeholder( + css_classes=["step-avatar-container"] + ) super().__init__(**params) self._render_avatar() self._title_pane = HTML( @@ -63,12 +72,39 @@ def __init__(self, **params): css_classes=["step-title"], ) self.header = Row( - self._avatar_container, + self._avatar_placeholder, self._title_pane, stylesheets=self._stylesheets + self.param.stylesheets.rx(), css_classes=["step-header"], ) + def __enter__(self): + self.status = "running" + if not self.title: + self.title = "Running..." + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.status = "completed" + if self.completed_title: + self.title = self.completed_title + elif self.objects: + obj = self.objects[-1] + for _ in range(100): + if hasattr(obj, "objects"): + obj = obj.objects[-1] + else: + break + + if hasattr(obj, "object"): + obj = obj.object + + if isinstance(obj, str): + self.title = obj + else: + self.title = "Completed!" + self.collapsed = True + @param.depends("status", watch=True) def _render_avatar(self): """ @@ -86,7 +122,6 @@ def _render_avatar(self): avatar_params = { "css_classes": ["step-avatar"], - "margin": (0, 5, 0, 0), "width": 15, "height": 15, } @@ -105,7 +140,7 @@ def _render_avatar(self): except ValueError: # likely an emoji avatar_pane = HTML(avatar, **avatar_params) - self._avatar_container.objects = [avatar_pane] + self._avatar_placeholder.update(avatar_pane) def stream_title(self, token, replace=False): if replace: @@ -114,49 +149,6 @@ def stream_title(self, token, replace=False): self.title += token return self.title - def __enter__(self): - self.status = "running" - if not self.title: - self.title = "Running..." - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.status = "completed" - if self.completed_title: - self.title = self.completed_title - elif self.objects: - obj = self.objects[-1] - for _ in range(100): - if hasattr(obj, "objects"): - obj = obj.objects[-1] - else: - break - - if hasattr(obj, "object"): - obj = obj.object - - if isinstance(obj, str): - self.title = obj - else: - self.title = "Completed!" - self.collapsed = True - - -class ChatStep(_ChatStepBase): - - completed_title = param.String( - default="Completed!", - doc="Title to display when status is completed; if not provided, uses the last object.", - ) - - collapsed = param.Boolean( - default=True, - doc=""" - Whether the contents of the Card are collapsed.""", - ) - - _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_step.css"] - def stream(self, token, replace=False, message=None): if message is None: message = Markdown(token, css_classes=["step-message"]) @@ -165,21 +157,34 @@ def stream(self, token, replace=False, message=None): message = stream_to(message, token, replace=replace) return message - def serialize(self): - ... + def serialize(self): ... -class ChatSteps(_ChatStepBase): +class ChatSteps(Column): - _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_steps.css"] + _stylesheets = [f"{CDN_DIST}css/chat_steps.css"] - def step(self, chat_step: ChatStep | None = None, **kwargs): - if chat_step is None: - chat_step = ChatStep(margin=0, **kwargs) - for kwarg in kwargs: - setattr(chat_step, kwarg, kwargs[kwarg]) - self.append(chat_step) - return chat_step + css_classes = param.List( + default=["chat-steps"], + doc="CSS classes to apply to the component.", + ) + + def __init__(self, **params): + super().__init__(**params) - def serialize(self): - ... + def start_step( + self, + *, + objects: str | list | None = None, + title: str | None = None, + **params, + ): + if objects: + if isinstance(objects, str): + objects = [objects] + params["objects"] = objects + if title is not None: + params["title"] = title + step = ChatStep(**params) + self.append(step) + return step diff --git a/panel/dist/css/chat_message.css b/panel/dist/css/chat_message.css index f921a864c0..3add5d9dba 100644 --- a/panel/dist/css/chat_message.css +++ b/panel/dist/css/chat_message.css @@ -59,6 +59,7 @@ .center { width: calc(100% - 15px); /* Without this, words start on a new line */ + min-height: 4em; margin-right: 10px; /* Space for reaction icons */ padding: 0px; } diff --git a/panel/dist/css/chat_step.css b/panel/dist/css/chat_step.css index 1fa54b6f0c..0dc2f19b27 100644 --- a/panel/dist/css/chat_step.css +++ b/panel/dist/css/chat_step.css @@ -1,14 +1,36 @@ /* inherit from chat_message */ :host(.card) { - outline: 0; box-shadow: none; background-color: var(--panel-surface-color, #f1f1f1); - margin-top: 0px; - width: 100%; } :host(.card) .card-header { border-bottom: 1px solid var(--panel-border-color, #e0e0e0); - padding-block: 5px; + background-color: var(--panel-surface-color, #f1f1f1); + padding-block: 15px; +} + +:host(.step-header) { + width: max-content; +} + +.step-title { + font-size: 1.25em; +} + +.step-message { + font-size: 1.25em; +} + +.step-avatar-container { + width: 15px; + height: 15px; + margin-inline: 5px; + margin-block: 3px; +} + +.step-avatar { + width: 15px; + height: 15px; } diff --git a/panel/dist/css/chat_steps.css b/panel/dist/css/chat_steps.css index 00488fe2cb..a1d2f81aec 100644 --- a/panel/dist/css/chat_steps.css +++ b/panel/dist/css/chat_steps.css @@ -1,15 +1,4 @@ -@import url('chat_step.css'); - -:host(.card) .card-header { - border-bottom: 1px solid var(--panel-border-color, #e0e0e0); - padding-block: 15px; -} - -.step-title { - font-size: 1.25em; -} - -.step-message { - width: 100%; - font-size: 1.25em; +.chat-steps { + padding-block: 0px; + padding-inline: 15px; } From 65af3fc8bd9c7358646324f4592ab07feb3c0dbb Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Wed, 15 May 2024 11:41:00 -0700 Subject: [PATCH 06/30] Tweak and add serialize --- panel/chat/message.py | 73 +-------------------- panel/chat/step.py | 121 +++++++++++++++++++++++++++-------- panel/chat/utils.py | 79 +++++++++++++++++++++-- panel/dist/css/chat_step.css | 2 + 4 files changed, 176 insertions(+), 99 deletions(-) diff --git a/panel/chat/message.py b/panel/chat/message.py index 18c433bdca..eacf4c98e6 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -11,9 +11,8 @@ from functools import partial from io import BytesIO from tempfile import NamedTemporaryFile -from textwrap import indent from typing import ( - TYPE_CHECKING, Any, Callable, ClassVar, Iterable, Optional, Union, + TYPE_CHECKING, Any, Callable, ClassVar, Optional, Union, ) from zoneinfo import ZoneInfo @@ -34,7 +33,7 @@ from ..viewable import Viewable from ..widgets.base import Widget from .icon import ChatCopyIcon, ChatReactionIcons -from .utils import avatar_lookup, stream_to +from .utils import avatar_lookup, serialize_recursively, stream_to if TYPE_CHECKING: from bokeh.document import Document @@ -335,70 +334,6 @@ def _build_layout(self): viewable_params['stylesheets'] = self._stylesheets + self.param.stylesheets.rx() self._composite = Row(left_col, right_col, **viewable_params) - def _get_obj_label(self, obj): - """ - Get the label for the object; defaults to specified object name; - if unspecified, defaults to the type name. - """ - label = obj.name - type_name = type(obj).__name__ - # If the name is just type + ID, simply use type - # e.g. Column10241 -> Column - if label.startswith(type_name) or not label: - label = type_name - return label - - def _serialize_recursively( - self, - obj: Any, - prefix_with_viewable_label: bool = True, - prefix_with_container_label: bool = True - ) -> str: - """ - Recursively serialize the object to a string. - """ - if isinstance(obj, Iterable) and not isinstance(obj, str): - content = tuple( - self._serialize_recursively( - o, - prefix_with_viewable_label=prefix_with_viewable_label, - prefix_with_container_label=prefix_with_container_label - ) for o in obj - ) - if prefix_with_container_label: - if len(content) == 1: - return f"{self._get_obj_label(obj)}({content[0]})" - else: - indented_content = indent(",\n".join(content), prefix=" " * 4) - # outputs like: - # Row( - # 1, - # "str", - # ) - return f"{self._get_obj_label(obj)}(\n{indented_content}\n)" - else: - # outputs like: - # (1, "str") - return f"({', '.join(content)})" - - string = obj - if hasattr(obj, "value"): - string = obj.value - elif hasattr(obj, "object"): - string = obj.object - - if hasattr(string, "decode") or isinstance(string, BytesIO): - self.param.warning( - f"Serializing byte-like objects are not supported yet; " - f"using the label of the object as a placeholder for {obj}" - ) - return self._get_obj_label(obj) - - if prefix_with_viewable_label and isinstance(obj, Viewable): - label = self._get_obj_label(obj) - string = f"{label}={string!r}" - return string - def __str__(self) -> str: """ Serialize the message object to a string. @@ -736,8 +671,6 @@ def serialize( Arguments --------- - obj : Any - The object to format. prefix_with_viewable_label : bool Whether to include the name of the Viewable, or type of the viewable if no name is specified. @@ -750,7 +683,7 @@ def serialize( str The serialized string. """ - return self._serialize_recursively( + return serialize_recursively( self.object, prefix_with_viewable_label=prefix_with_viewable_label, prefix_with_container_label=prefix_with_container_label, diff --git a/panel/chat/step.py b/panel/chat/step.py index d4a627766c..1b2a7e3e11 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -6,11 +6,11 @@ from ..io.resources import CDN_DIST from ..layout import Card, Column, Row from ..pane.image import Image -from ..pane.markup import HTML, Markdown +from ..pane.markup import HTML, HTMLBasePane, Markdown from ..pane.placeholder import Placeholder from ..viewable import Viewable from ..widgets.indicators import BooleanStatus, LoadingSpinner -from .utils import avatar_lookup, stream_to +from .utils import avatar_lookup, serialize_recursively, stream_to DEFAULT_STATUS_AVATARS = { "pending": BooleanStatus(value=False, margin=0, color="warning"), @@ -61,9 +61,7 @@ class ChatStep(Card): def __init__(self, **params): self._instance = None - self._avatar_placeholder = Placeholder( - css_classes=["step-avatar-container"] - ) + self._avatar_placeholder = Placeholder(css_classes=["step-avatar-container"]) super().__init__(**params) self._render_avatar() self._title_pane = HTML( @@ -149,15 +147,56 @@ def stream_title(self, token, replace=False): self.title += token return self.title - def stream(self, token, replace=False, message=None): - if message is None: + def stream(self, token: str, replace=False): + """ + Stream a token to the message pane. + + Arguments + --------- + token : str + The token to stream. + replace : bool + Whether to replace the existing text. + + Returns + ------- + Viewable + The updated message pane. + """ + if len(self.objects) == 0 or not isinstance(self.objects[-1], HTMLBasePane): message = Markdown(token, css_classes=["step-message"]) self.append(message) else: - message = stream_to(message, token, replace=replace) + message = stream_to(self.objects[-1], token, replace=replace) return message - def serialize(self): ... + def serialize( + self, + prefix_with_viewable_label: bool = True, + prefix_with_container_label: bool = True, + ) -> str: + """ + Format the object to a string. + + Arguments + --------- + prefix_with_viewable_label : bool + Whether to include the name of the Viewable, or type + of the viewable if no name is specified. + prefix_with_container_label : bool + Whether to include the name of the container, or type + of the container if no name is specified. + + Returns + ------- + str + The serialized string. + """ + return serialize_recursively( + self, + prefix_with_viewable_label=prefix_with_viewable_label, + prefix_with_container_label=prefix_with_container_label, + ) class ChatSteps(Column): @@ -169,22 +208,54 @@ class ChatSteps(Column): doc="CSS classes to apply to the component.", ) - def __init__(self, **params): - super().__init__(**params) + @param.depends("objects", watch=True, on_init=True) + def _validate_steps(self): + for step in self.objects: + if not isinstance(step, ChatStep): + raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - def start_step( - self, - *, - objects: str | list | None = None, - title: str | None = None, - **params, - ): - if objects: - if isinstance(objects, str): - objects = [objects] - params["objects"] = objects - if title is not None: - params["title"] = title - step = ChatStep(**params) + def create_step(self, **step_params): + """ + Create a new ChatStep and append it to the ChatSteps. + + Arguments + --------- + **step_params : dict + Parameters to pass to the ChatStep constructor. + + Returns + ------- + ChatStep + The newly created ChatStep. + """ + step = ChatStep(**step_params) self.append(step) return step + + def serialize( + self, + prefix_with_viewable_label: bool = True, + prefix_with_container_label: bool = True, + ) -> str: + """ + Format the objects to a string. + + Arguments + --------- + prefix_with_viewable_label : bool + Whether to include the name of the Viewable, or type + of the viewable if no name is specified. + prefix_with_container_label : bool + Whether to include the name of the container, or type + of the container if no name is specified. + + Returns + ------- + str + The serialized string. + """ + return serialize_recursively( + self, + prefix_with_viewable_label=prefix_with_viewable_label, + prefix_with_container_label=prefix_with_container_label, + ) diff --git a/panel/chat/utils.py b/panel/chat/utils.py index 0780a2a3aa..6380b7e201 100644 --- a/panel/chat/utils.py +++ b/panel/chat/utils.py @@ -2,14 +2,19 @@ import re +from collections.abc import Iterable from io import BytesIO -from typing import Any, Dict, Union +from textwrap import indent +from typing import Any, Union + +import param from ..io.resources import CDN_DIST from ..pane.image import ImageBase +from ..viewable import Viewable Avatar = Union[str, BytesIO, bytes, ImageBase] -AvatarDict = Dict[str, Avatar] +AvatarDict = dict[str, Avatar] def to_alpha_numeric(user: str) -> str: @@ -23,8 +28,8 @@ def to_alpha_numeric(user: str) -> str: def avatar_lookup( user: str, avatar: Any, - avatars: Dict[str, Any], - default_avatars: Dict[str, Any], + avatars: dict[str, Any], + default_avatars: dict[str, Any], ) -> Avatar: """ Lookup the avatar for the user. @@ -88,3 +93,69 @@ def stream_to(obj, token, replace=False): contents = token if replace else obj + token setattr(object_panel, attr, contents) return object_panel + + +def get_obj_label(obj): + """ + Get the label for the object; defaults to specified object name; + if unspecified, defaults to the type name. + """ + label = obj.name + type_name = type(obj).__name__ + # If the name is just type + ID, simply use type + # e.g. Column10241 -> Column + if label.startswith(type_name) or not label: + label = type_name + return label + + +def serialize_recursively( + obj: Any, + prefix_with_viewable_label: bool = True, + prefix_with_container_label: bool = True, +) -> str: + """ + Recursively serialize the object to a string. + """ + if isinstance(obj, Iterable) and not isinstance(obj, str): + content = tuple( + serialize_recursively( + o, + prefix_with_viewable_label=prefix_with_viewable_label, + prefix_with_container_label=prefix_with_container_label, + ) + for o in obj + ) + if prefix_with_container_label: + if len(content) == 1: + return f"{get_obj_label(obj)}({content[0]})" + else: + indented_content = indent(",\n".join(content), prefix=" " * 4) + # outputs like: + # Row( + # 1, + # "str", + # ) + return f"{get_obj_label(obj)}(\n{indented_content}\n)" + else: + # outputs like: + # (1, "str") + return f"({', '.join(content)})" + + string = obj + if hasattr(obj, "value"): + string = obj.value + elif hasattr(obj, "object"): + string = obj.object + + if hasattr(string, "decode") or isinstance(string, BytesIO): + param.warning( + f"Serializing byte-like objects are not supported yet; " + f"using the label of the object as a placeholder for {obj}" + ) + return get_obj_label(obj) + + if prefix_with_viewable_label and isinstance(obj, Viewable): + label = get_obj_label(obj) + string = f"{label}={string!r}" + return string diff --git a/panel/dist/css/chat_step.css b/panel/dist/css/chat_step.css index 0dc2f19b27..d0f336dab7 100644 --- a/panel/dist/css/chat_step.css +++ b/panel/dist/css/chat_step.css @@ -21,6 +21,8 @@ .step-message { font-size: 1.25em; + padding-block: 0px; + padding-inline: 7px; } .step-avatar-container { From 3f4255b04c4ce76548eae565ce02cd0c847b9c8d Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 20 May 2024 12:26:43 -0700 Subject: [PATCH 07/30] Refactor --- panel/chat/feed.py | 22 +++-- panel/chat/message.py | 27 ++---- panel/chat/step.py | 189 +++++++++++++++++++++++++----------------- panel/chat/utils.py | 28 +++++++ 4 files changed, 165 insertions(+), 101 deletions(-) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 020eb31f97..7e3f9bbfd6 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -683,6 +683,23 @@ def stream( self.param.trigger("_post_hook_trigger") return message + def stream_steps(self, **params): + """ + Creates a new ChatSteps component and streams it. + + Arguments + --------- + message_params : dict + Parameters to pass to the ChatSteps. + + Returns + ------- + The ChatSteps that was created. + """ + steps = ChatSteps(**params) + self.stream(steps) + return steps + def respond(self): """ Executes the callback with the latest message in the chat log. @@ -893,8 +910,3 @@ def select(self, selector=None): (callable(selector) and not isinstance(selector, type) and selector(self))): selected.append(self) return selected + self._card.select(selector) - - def steps(self): - steps = ChatSteps() - self.send(steps, respond=False) - return steps diff --git a/panel/chat/message.py b/panel/chat/message.py index eacf4c98e6..901a82721d 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -33,7 +33,9 @@ from ..viewable import Viewable from ..widgets.base import Widget from .icon import ChatCopyIcon, ChatReactionIcons -from .utils import avatar_lookup, serialize_recursively, stream_to +from .utils import ( + avatar_lookup, build_avatar_pane, serialize_recursively, stream_to, +) if TYPE_CHECKING: from bokeh.document import Document @@ -503,26 +505,9 @@ def _render_avatar(self) -> HTML | Image: if not avatar and self.user: avatar = self.user[0] - avatar_params = {'css_classes': ["avatar"]} - if isinstance(avatar, ImageBase): - avatar_pane = avatar - avatar_params['css_classes'] = ( - avatar_params.get('css_classes', []) + - avatar_pane.css_classes - ) - avatar_params.update(width=35, height=35) - avatar_pane.param.update(avatar_params) - elif not isinstance(avatar, (BytesIO, bytes)) and len(avatar) == 1: - # single character - avatar_pane = HTML(avatar, **avatar_params) - else: - try: - avatar_pane = Image( - avatar, width=35, height=35, **avatar_params - ) - except ValueError: - # likely an emoji - avatar_pane = HTML(avatar, **avatar_params) + avatar_pane = build_avatar_pane( + avatar, css_classes=["avatar"], height=35, width=35 + ) return avatar_pane def _update_avatar_pane(self, event=None): diff --git a/panel/chat/step.py b/panel/chat/step.py index 1b2a7e3e11..a71b35e2bb 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -1,58 +1,84 @@ -from io import BytesIO from typing import ClassVar, Mapping import param from ..io.resources import CDN_DIST from ..layout import Card, Column, Row -from ..pane.image import Image from ..pane.markup import HTML, HTMLBasePane, Markdown from ..pane.placeholder import Placeholder -from ..viewable import Viewable -from ..widgets.indicators import BooleanStatus, LoadingSpinner -from .utils import avatar_lookup, serialize_recursively, stream_to +from ..widgets.indicators import BooleanStatus +from .utils import ( + avatar_lookup, build_avatar_pane, serialize_recursively, stream_to, +) DEFAULT_STATUS_AVATARS = { - "pending": BooleanStatus(value=False, margin=0, color="warning"), - "running": LoadingSpinner(value=True, margin=0), + "pending": BooleanStatus(value=False, margin=0, color="primary"), + "running": BooleanStatus(value=True, margin=0, color="warning"), "completed": BooleanStatus(value=True, margin=0, color="success"), - "failed": BooleanStatus(value=False, margin=0, color="danger"), + "failed": BooleanStatus(value=True, margin=0, color="danger"), } class ChatStep(Card): + collapsed = param.Boolean( + default=False, + doc="Whether the contents of the Card are collapsed.") - status = param.Selector( - default="pending", objects=["pending", "running", "completed", "failed"] - ) + collapse_on_completed = param.Boolean( + default=True, + doc="Whether to collapse the card on completion.") completed_title = param.String( - default="Completed!", - doc="Title to display when status is completed; if not provided, uses the last object.", - ) - - collapsed = param.Boolean( - default=False, - doc=""" - Whether the contents of the Card are collapsed.""", - ) + default=None, + doc=( + "Title to display when status is completed; if not provided and collapse_on_completed" + "uses the last object's string.") + ) default_avatars = param.Dict( default=DEFAULT_STATUS_AVATARS, - doc="Mapping from status to default status avatar", + doc="Mapping from status to default status avatar") + + default_title = param.String( + default="", + doc="The default title to display if the other title params are unset." ) + failed_title = param.String( + default=None, + doc="Title to display when status is failed.") + margin = param.Parameter( - default=(5, 5, 5, 10), - doc=""" + default=(5, 5, 5, 10), doc=""" Allows to create additional space around the component. May be specified as a two-tuple of the form (vertical, horizontal) - or a four-tuple (top, right, bottom, left).""", + or a four-tuple (top, right, bottom, left).""") + + pending_title = param.String( + default=None, + doc="Title to display when status is pending." + ) + + running_title = param.String( + default=None, + doc="Title to display when status is running." ) + status = param.Selector( + default="pending", objects=["pending", "running", "completed", "failed"]) + + title = param.String(default="", constant=True, doc=""" + The title of the chat step. Will redirect to default_title on init. + After, it cannot be set directly; instead use the *_title params.""") + _rename: ClassVar[Mapping[str, str | None]] = { + "collapse_on_completed": None, "default_avatars": None, + "default_title": None, + "pending_title": None, + "running_title": None, "completed_title": None, + "failed_title": None, "status": None, **Card._rename, } @@ -62,6 +88,12 @@ class ChatStep(Card): def __init__(self, **params): self._instance = None self._avatar_placeholder = Placeholder(css_classes=["step-avatar-container"]) + + if params.get("title"): + if params.get("default_title"): + raise ValueError("Cannot set both title and default_title.") + params["default_title"] = params["title"] + super().__init__(**params) self._render_avatar() self._title_pane = HTML( @@ -75,33 +107,18 @@ def __init__(self, **params): stylesheets=self._stylesheets + self.param.stylesheets.rx(), css_classes=["step-header"], ) - def __enter__(self): self.status = "running" - if not self.title: - self.title = "Running..." return self def __exit__(self, exc_type, exc_value, traceback): + if exc_type is not None: + self.status = "failed" + if self.failed_title is None: + # convert to str to wrap repr in apostrophes + self.failed_title = f"Failed due to: {str(exc_value)!r}" + raise exc_value self.status = "completed" - if self.completed_title: - self.title = self.completed_title - elif self.objects: - obj = self.objects[-1] - for _ in range(100): - if hasattr(obj, "objects"): - obj = obj.objects[-1] - else: - break - - if hasattr(obj, "object"): - obj = obj.object - - if isinstance(obj, str): - self.title = obj - else: - self.title = "Completed!" - self.collapsed = True @param.depends("status", watch=True) def _render_avatar(self): @@ -114,40 +131,63 @@ def _render_avatar(self): self.default_avatars, DEFAULT_STATUS_AVATARS, ) - - if not avatar and self.user: - avatar = self.user[0] - - avatar_params = { - "css_classes": ["step-avatar"], - "width": 15, - "height": 15, - } - if isinstance(avatar, Viewable): - avatar_pane = avatar - avatar_params["css_classes"] = ( - avatar_params.get("css_classes", []) + avatar_pane.css_classes - ) - avatar_pane.param.update(avatar_params) - elif not isinstance(avatar, (BytesIO, bytes)) and len(avatar) == 1: - # single character - avatar_pane = HTML(avatar, **avatar_params) - else: - try: - avatar_pane = Image(avatar, **avatar_params) - except ValueError: - # likely an emoji - avatar_pane = HTML(avatar, **avatar_params) + avatar_pane = build_avatar_pane(avatar) self._avatar_placeholder.update(avatar_pane) - def stream_title(self, token, replace=False): + @param.depends( + "status", + "default_title", + "pending_title", + "running_title", + "completed_title", + "failed_title", + watch=True, + on_init=True, + ) + def _update_title_on_status(self): + with param.edit_constant(self): + if self.status == "pending" and self.pending_title is not None: + self.title = self.pending_title + + elif self.status == "running" and self.running_title is not None: + self.title = self.running_title + + elif self.status == "completed": + if self.completed_title: + self.title = self.completed_title + elif self.objects and self.collapse_on_completed: + obj = self.objects[-1] + for _ in range(100): + if hasattr(obj, "objects"): + obj = obj.objects[-1] + else: + break + + if hasattr(obj, "object"): + obj = obj.object + + if isinstance(obj, str): + self.title = obj + else: + self.title = self.default_title + elif self.status == "failed" and self.failed_title is not None: + self.title = self.failed_title + + else: + self.title = self.default_title + + @param.depends("status", "collapse_on_completed", watch=True) + def _update_collapsed(self): + if self.status == "completed" and self.collapse_on_completed: + self.collapsed = True + + def stream_title(self, token: str, replace: bool = False): if replace: self.title = token else: self.title += token - return self.title - def stream(self, token: str, replace=False): + def stream(self, token: str, replace: bool = False): """ Stream a token to the message pane. @@ -167,8 +207,7 @@ def stream(self, token: str, replace=False): message = Markdown(token, css_classes=["step-message"]) self.append(message) else: - message = stream_to(self.objects[-1], token, replace=replace) - return message + stream_to(self.objects[-1], token, replace=replace) def serialize( self, @@ -214,7 +253,7 @@ def _validate_steps(self): if not isinstance(step, ChatStep): raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - def create_step(self, **step_params): + def append_step(self, **step_params): """ Create a new ChatStep and append it to the ChatSteps. diff --git a/panel/chat/utils.py b/panel/chat/utils.py index 6380b7e201..6354c0dec6 100644 --- a/panel/chat/utils.py +++ b/panel/chat/utils.py @@ -9,6 +9,8 @@ import param +from panel.pane import HTML, Image + from ..io.resources import CDN_DIST from ..pane.image import ImageBase from ..viewable import Viewable @@ -51,6 +53,32 @@ def avatar_lookup( return avatar +def build_avatar_pane( + avatar: Any, css_class: str, width: int = 15, height: int = 15 +) -> Image | HTML: + avatar_params = { + "css_classes": [css_class], + "width": width, + "height": height, + } + if isinstance(avatar, Viewable): + avatar_pane = avatar + avatar_params["css_classes"] = ( + avatar_params.get("css_classes", []) + avatar_pane.css_classes + ) + avatar_pane.param.update(avatar_params) + elif not isinstance(avatar, (BytesIO, bytes)) and len(avatar) == 1: + # single character + avatar_pane = HTML(avatar, **avatar_params) + else: + try: + avatar_pane = Image(avatar, **avatar_params) + except ValueError: + # likely an emoji + avatar_pane = HTML(avatar, **avatar_params) + return avatar_pane + + def stream_to(obj, token, replace=False): """ Updates the message with the new token traversing the object to From f1a99b22c263830122c30e3a354b29a5ce411486 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 20 May 2024 12:36:15 -0700 Subject: [PATCH 08/30] add tests --- panel/tests/chat/test_feed.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 9fa128253b..90b900de26 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -6,6 +6,7 @@ from panel.chat.feed import ChatFeed from panel.chat.icon import ChatReactionIcons from panel.chat.message import ChatMessage +from panel.chat.step import ChatStep, ChatSteps from panel.layout import Column, Row from panel.pane.image import Image from panel.pane.markup import HTML @@ -188,6 +189,13 @@ def test_stream(self, chat_feed): assert chat_feed.objects[1] is new_entry assert chat_feed.objects[1].object == "New message" + def test_stream_steps(self, chat_feed): + chat_step = ChatStep(title="Testing...") + chat_steps = chat_feed.stream_steps(objects=[chat_step]) + assert isinstance(chat_steps, ChatSteps) + assert chat_steps == chat_feed[0].object + assert chat_steps[0] == chat_step + def test_stream_with_user_avatar(self, chat_feed): user = "Bob" avatar = "👨" From c428324f97db61cb4f00306050106b858891f138 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 20 May 2024 13:40:04 -0700 Subject: [PATCH 09/30] mention in chat feed --- examples/reference/chat/ChatFeed.ipynb | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 9a64d1329e..9d193887e2 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -597,6 +597,71 @@ "message = chat_feed.send(\"Hello bots!\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Steps\n", + "\n", + "Intermediate steps, like chain of thoughts, can be provided through a series of [`ChatStep`](ChatStep.ipynb), which is conveniently accessible through `stream_steps` and `append_step`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_feed = pn.chat.ChatFeed()\n", + "chat_feed" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "steps = chat_feed.stream_steps()\n", + "with steps.append_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\", status=\"running\") as step:\n", + " step.stream(\"\\n\\n...Okay the plan is to demo this!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Multiple steps can be initialized." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "steps.append_step(title=\"The next thing is to come up with features to demo...\", status=\"pending\")\n", + "steps.append_step(title=\"TBD\", status=\"pending\")\n", + "steps.append_step(title=\"TBD\", status=\"pending\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then a context manager can be used to \"process\" them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with steps[1] as step:\n", + " step.stream(\"\\n\\n...Okay, we can demo the context manager\")" + ] + }, { "cell_type": "markdown", "metadata": {}, From 24d31d0164089264cb85721f162ed885c7a99723 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 20 May 2024 13:40:46 -0700 Subject: [PATCH 10/30] fix docs and remove chat steps from public --- panel/chat/__init__.py | 2 +- panel/chat/step.py | 39 +++++++++++++++++++++++++++------------ panel/chat/utils.py | 4 ++-- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index 7e48a71ce1..729fbb531a 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -36,7 +36,7 @@ from .input import ChatAreaInput # noqa from .interface import ChatInterface # noqa from .message import ChatMessage # noqa -from .step import ChatStep, ChatSteps # noqa +from .step import ChatStep # noqa def __getattr__(name): diff --git a/panel/chat/step.py b/panel/chat/step.py index a71b35e2bb..c5a232b9bc 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -4,6 +4,7 @@ from ..io.resources import CDN_DIST from ..layout import Card, Column, Row +from ..pane.image import ImageBase from ..pane.markup import HTML, HTMLBasePane, Markdown from ..pane.placeholder import Placeholder from ..widgets.indicators import BooleanStatus @@ -28,12 +29,9 @@ class ChatStep(Card): default=True, doc="Whether to collapse the card on completion.") - completed_title = param.String( - default=None, - doc=( - "Title to display when status is completed; if not provided and collapse_on_completed" - "uses the last object's string.") - ) + completed_title = param.String(default=None, doc=""" + Title to display when status is completed; if not provided and collapse_on_completed + uses the last object's string.""") default_avatars = param.Dict( default=DEFAULT_STATUS_AVATARS, @@ -41,8 +39,7 @@ class ChatStep(Card): default_title = param.String( default="", - doc="The default title to display if the other title params are unset." - ) + doc="The default title to display if the other title params are unset.") failed_title = param.String( default=None, @@ -131,7 +128,7 @@ def _render_avatar(self): self.default_avatars, DEFAULT_STATUS_AVATARS, ) - avatar_pane = build_avatar_pane(avatar) + avatar_pane = build_avatar_pane(avatar, ["step-avatar"]) self._avatar_placeholder.update(avatar_pane) @param.depends( @@ -182,6 +179,16 @@ def _update_collapsed(self): self.collapsed = True def stream_title(self, token: str, replace: bool = False): + """ + Stream a token to the title header. + + Arguments: + --------- + token : str + The token to stream. + replace : bool + Whether to replace the existing text. + """ if replace: self.title = token else: @@ -189,7 +196,7 @@ def stream_title(self, token: str, replace: bool = False): def stream(self, token: str, replace: bool = False): """ - Stream a token to the message pane. + Stream a token to the last available string-like object. Arguments --------- @@ -203,7 +210,8 @@ def stream(self, token: str, replace: bool = False): Viewable The updated message pane. """ - if len(self.objects) == 0 or not isinstance(self.objects[-1], HTMLBasePane): + if ( + len(self.objects) == 0 or not isinstance(self.objects[-1], HTMLBasePane) or isinstance(self.objects[-1], ImageBase)): message = Markdown(token, css_classes=["step-message"]) self.append(message) else: @@ -237,6 +245,9 @@ def serialize( prefix_with_container_label=prefix_with_container_label, ) + def __str__(self): + return self.serialize() + class ChatSteps(Column): @@ -253,7 +264,7 @@ def _validate_steps(self): if not isinstance(step, ChatStep): raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - def append_step(self, **step_params): + def append_step(self, objects: str | list[str] | None = None, **step_params): """ Create a new ChatStep and append it to the ChatSteps. @@ -267,6 +278,10 @@ def append_step(self, **step_params): ChatStep The newly created ChatStep. """ + if objects is not None: + if not isinstance(objects, list): + objects = [objects] + step_params["objects"] = objects step = ChatStep(**step_params) self.append(step) return step diff --git a/panel/chat/utils.py b/panel/chat/utils.py index 6354c0dec6..c39e7758f6 100644 --- a/panel/chat/utils.py +++ b/panel/chat/utils.py @@ -54,10 +54,10 @@ def avatar_lookup( def build_avatar_pane( - avatar: Any, css_class: str, width: int = 15, height: int = 15 + avatar: Any, css_classes: list[str], width: int = 15, height: int = 15 ) -> Image | HTML: avatar_params = { - "css_classes": [css_class], + "css_classes": css_classes, "width": width, "height": height, } From d2b8ae8497dd1bf4c7c549179d1e8f2269964aa8 Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Mon, 20 May 2024 13:42:16 -0700 Subject: [PATCH 11/30] Update panel/chat/feed.py --- panel/chat/feed.py | 1 - 1 file changed, 1 deletion(-) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 7e3f9bbfd6..a61c149ce4 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -198,7 +198,6 @@ class ChatFeed(ListPanel): _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_feed.css"] def __init__(self, *objects, **params): - self._steps = None self._callback_future = None if params.get("renderers") and not isinstance(params["renderers"], list): From 1fbaac662d8e33887eebac7e215fa441e04c091b Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 20 May 2024 13:44:57 -0700 Subject: [PATCH 12/30] forgot to add this notebook --- examples/reference/chat/ChatStep.ipynb | 348 +++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 examples/reference/chat/ChatStep.ipynb diff --git a/examples/reference/chat/ChatStep.ipynb b/examples/reference/chat/ChatStep.ipynb new file mode 100644 index 0000000000..3fa542a182 --- /dev/null +++ b/examples/reference/chat/ChatStep.ipynb @@ -0,0 +1,348 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `ChatStep` opens up the possibility to display / hide intermediate steps prior to the final result.\n", + "\n", + "Check out the [panel-chat-examples](https://holoviz-topics.github.io/panel-chat-examples/) docs to see applicable examples related to [LangChain](https://python.langchain.com/docs/get_started/introduction), [OpenAI](https://openai.com/blog/chatgpt), [Mistral](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwjZtP35yvSBAxU00wIHHerUDZAQFnoECBEQAQ&url=https%3A%2F%2Fdocs.mistral.ai%2F&usg=AOvVaw2qpx09O_zOzSksgjBKiJY_&opi=89978449), [Llama](https://ai.meta.com/llama/), etc. If you have an example to demo, we'd love to add it to the panel-chat-examples gallery!\n", + "\n", + "#### Parameters:\n", + "\n", + "##### Core\n", + "\n", + "* **`collapse_on_completed`** (`bool`): Whether to collapse the card on completion. Defaults to `True`.\n", + "* **`completed_title`** (`str`): Title to display when status is completed; if not provided and `collapse_on_completed` uses the last object's string. Defaults to `None`.\n", + "* **`default_title`** (`str`): The default title to display if the other title params are unset. Defaults to an empty string `\"\"`.\n", + "* **`failed_title`** (`str`): Title to display when status is failed. Defaults to `None`.\n", + "* **`margin`** (`tuple`): Allows creating additional space around the component. May be specified as a two-tuple of the form (vertical, horizontal) or a four-tuple (top, right, bottom, left). Defaults to `(5, 5, 5, 10)`.\n", + "* **``objects``** (list): The list of objects to display in the `ChatStep`, which will be formatted like a `Column`. Should not generally be modified directly except when replaced in its entirety.\n", + "* **`pending_title`** (`str`): Title to display when status is pending. Defaults to `None`.\n", + "* **`running_title`** (`str`): Title to display when status is running. Defaults to `None`.\n", + "* **`status`** (`str`): The status of the chat step. Must be one of [\"pending\", \"running\", \"completed\", \"failed\"]. Defaults to `\"pending\"`.\n", + "\n", + "##### Styling\n", + "\n", + "* **`collapsed`** (`bool`): Whether the contents of the `ChatStep` are collapsed. Defaults to `False`.\n", + "* **`default_avatars`** (`dict`): Mapping from status to default status avatar. Defaults to `DEFAULT_STATUS_AVATARS`.\n", + "\n", + "#### Methods\n", + "\n", + "##### Core\n", + "\n", + "* **`stream`**: Stream a token to the last available string-like object.\n", + "* **`stream_title`**: Stream a token to the title header.\n", + "\n", + "___" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Basics" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`ChatStep` can be initialized without any arguments." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep()\n", + "chat_step" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Append `Markdown` objects to the chat step by calling `stream`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.stream(\"Just thinking...\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Calling it again will concatenate the text to the last available `Markdown` pane." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.stream(\" about `ChatStep`!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Alternatively, setting `replace=True` will override the existing message." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.stream(\"`ChatStep` can do a lot of things!\", replace=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It's possible to also append any objects, like images." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.append(pn.pane.Image(\"https://assets.holoviz.org/panel/samples/png_sample.png\", width=50, height=50))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Calling `stream` afterwards will append a new `Markdown` pane below it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.stream(\"Like it can support images too! Above is a picture of dices.\")\n", + "print(chat_step.objects)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To convert the objects into a string, call `serialize`, or simply use `str()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.serialize()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Avatars\n", + "\n", + "The default avatars are `BooleanStatus`, but can be changed by providing `default_avatars`. The values can be emojis, images, text, or Panel objects." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep(\n", + " default_avatars={\n", + " \"pending\": \"🤔\",\n", + " \"running\": \"🏃\",\n", + " \"completed\": \"🎉\",\n", + " \"failed\": \"😞\",\n", + " },\n", + " status=\"completed\",\n", + ")\n", + "chat_step" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Status" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To show that the step is processing, you can set the status to `running` and provide a `running_title`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\")\n", + "chat_step.stream(\"Pretending to do something.\")\n", + "chat_step" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Upon completion, set the status to `completed` and it'll automatically collapse the contents and use the last `str` object as the title." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.status = \"completed\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To prevent the title from inheriting the last `str` object as the title, set `collapse_on_completed=False`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\", collapse_on_completed=False)\n", + "chat_step.stream(\"Pretending to do something.\")\n", + "chat_step.status = \"completed\"\n", + "chat_step" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Alternatively, provide the completed title." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\", completed_title=\"Pretend job done!\")\n", + "chat_step.stream(\"Pretending to do something.\")\n", + "chat_step.status = \"completed\"\n", + "chat_step" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To streamline this process, `ChatStep` may be used as a context manager.\n", + "\n", + "Upon entry, the status will be changed from the default `pending` to `running`.\n", + "\n", + "Exiting will change the status from `running` to `completed` if successful." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep(running_title=\"Processing this step...\", completed_title=\"Pretend job done!\")\n", + "with chat_step:\n", + " chat_step.stream(\"Pretending to do something.\")\n", + "\n", + "chat_step" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "However, if an exception occurs, the status will be set to \"failed\" and the error message will be displayed on the title." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep(running_title=\"Processing this step...\", completed_title=\"Pretend job done!\")\n", + "chat_step" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with chat_step:\n", + " chat_step.stream(\"Breaking something\")\n", + " raise RuntimeError(\"Just demoing!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Be sure to check out [ChatFeed](ChatFeed.ipynb) to see it works with a `ChatFeed` or `ChatInterface`!" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 4be9ce9239fb4b1821eb2fd825e8662d2eeedc8a Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 20 May 2024 15:56:35 -0700 Subject: [PATCH 13/30] fix tests --- panel/chat/message.py | 2 +- panel/chat/step.py | 2 ++ panel/chat/utils.py | 3 +-- panel/tests/chat/test_feed.py | 10 ++++++++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/panel/chat/message.py b/panel/chat/message.py index 901a82721d..9ac90bb6ff 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -603,7 +603,7 @@ def stream(self, token: str, replace: bool = False): replace: bool (default=False) Whether to replace the existing text. """ - stream_to(obj=self.object, token=token, replace=replace) + stream_to(obj=self.object, token=token, replace=replace, object_panel=self) def update( self, diff --git a/panel/chat/step.py b/panel/chat/step.py index c5a232b9bc..8bc51b599d 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import ClassVar, Mapping import param diff --git a/panel/chat/utils.py b/panel/chat/utils.py index c39e7758f6..0c9f260af5 100644 --- a/panel/chat/utils.py +++ b/panel/chat/utils.py @@ -79,7 +79,7 @@ def build_avatar_pane( return avatar_pane -def stream_to(obj, token, replace=False): +def stream_to(obj, token, replace=False, object_panel=None): """ Updates the message with the new token traversing the object to allow updating nested objects. When traversing a nested Panel @@ -96,7 +96,6 @@ def stream_to(obj, token, replace=False): """ i = -1 parent_panel = None - object_panel = None attr = "object" if obj is None: obj = "" diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 90b900de26..c64497140d 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -5,8 +5,9 @@ from panel.chat.feed import ChatFeed from panel.chat.icon import ChatReactionIcons -from panel.chat.message import ChatMessage +from panel.chat.message import DEFAULT_AVATARS, ChatMessage from panel.chat.step import ChatStep, ChatSteps +from panel.chat.utils import avatar_lookup from panel.layout import Column, Row from panel.pane.image import Image from panel.pane.markup import HTML @@ -393,7 +394,12 @@ def callback(contents, user, instance): chat_feed.send("Message", respond=True) wait_until(lambda: len(chat_feed.objects) == 2) assert chat_feed.objects[1].user == "System" - assert chat_feed.objects[1].avatar == ChatMessage()._avatar_lookup("System") + assert chat_feed.objects[1].avatar == avatar_lookup( + "System", + None, + {}, + default_avatars=DEFAULT_AVATARS + ) def test_default_avatars_message_params(self, chat_feed): chat_feed.message_params["default_avatars"] = {"test1": "1"} From 36ef00a5b2e0f348e8f23e8b5a733a1822682045 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 20 May 2024 16:10:03 -0700 Subject: [PATCH 14/30] fix more tests --- panel/chat/step.py | 4 ++-- panel/chat/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/chat/step.py b/panel/chat/step.py index 8bc51b599d..38f552ed73 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -84,7 +84,7 @@ class ChatStep(Card): _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_step.css"] - def __init__(self, **params): + def __init__(self, *objects, **params): self._instance = None self._avatar_placeholder = Placeholder(css_classes=["step-avatar-container"]) @@ -93,7 +93,7 @@ def __init__(self, **params): raise ValueError("Cannot set both title and default_title.") params["default_title"] = params["title"] - super().__init__(**params) + super().__init__(*objects, **params) self._render_avatar() self._title_pane = HTML( self.param.title, diff --git a/panel/chat/utils.py b/panel/chat/utils.py index 0c9f260af5..1e1e308fb5 100644 --- a/panel/chat/utils.py +++ b/panel/chat/utils.py @@ -176,7 +176,7 @@ def serialize_recursively( string = obj.object if hasattr(string, "decode") or isinstance(string, BytesIO): - param.warning( + param.main.param.warning( f"Serializing byte-like objects are not supported yet; " f"using the label of the object as a placeholder for {obj}" ) From 7364487199a75dfd5e5c48ffedca3164cc8ab2c2 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Wed, 22 May 2024 23:04:37 -0700 Subject: [PATCH 15/30] Add methods --- panel/chat/__init__.py | 2 +- panel/chat/feed.py | 37 ++++++++++++++++++++++++++++++++--- panel/chat/step.py | 33 +++++++++++++++++++++++++++---- panel/dist/css/chat_steps.css | 4 ++-- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index 729fbb531a..7e48a71ce1 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -36,7 +36,7 @@ from .input import ChatAreaInput # noqa from .interface import ChatInterface # noqa from .message import ChatMessage # noqa -from .step import ChatStep # noqa +from .step import ChatStep, ChatSteps # noqa def __getattr__(name): diff --git a/panel/chat/feed.py b/panel/chat/feed.py index a61c149ce4..6e8ae3c5ec 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -682,13 +682,24 @@ def stream( self.param.trigger("_post_hook_trigger") return message - def stream_steps(self, **params): + def stream_steps( + self, + user: str | None = None, + avatar: str | bytes | BytesIO | None = None, + **params + ): """ Creates a new ChatSteps component and streams it. Arguments --------- - message_params : dict + user : str | None + The user to stream as; overrides the message's user if provided. + Will default to the user parameter. + avatar : str | bytes | BytesIO | None + The avatar to use; overrides the message's avatar if provided. + Will default to the avatar parameter. + params : dict Parameters to pass to the ChatSteps. Returns @@ -696,9 +707,29 @@ def stream_steps(self, **params): The ChatSteps that was created. """ steps = ChatSteps(**params) - self.stream(steps) + self.stream(steps, user=user, avatar=avatar) return steps + def stream_step(self, objects: str | list[str] | None = None, **step_params): + """ + Streams a step to the latest, active ChatSteps component. + + Arguments + --------- + objects : str | list(str) | None + The objects to stream to the step. + step_params : dict + Parameters to pass to the ChatStep. + """ + for message in reversed(self._chat_log.objects): + obj = message.object + if isinstance(obj, ChatSteps): + if not obj.active: + raise ValueError("Cannot stream a step to an inactive ChatSteps component") + return obj.create_step(objects, **step_params) + else: + raise ValueError("No active ChatSteps component found") + def respond(self): """ Executes the callback with the latest message in the chat log. diff --git a/panel/chat/step.py b/panel/chat/step.py index 38f552ed73..d1ff1ccc8b 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -253,25 +253,40 @@ def __str__(self): class ChatSteps(Column): - _stylesheets = [f"{CDN_DIST}css/chat_steps.css"] + + step_params = param.Dict( + default={}, + doc="Parameters to pass to the ChatStep constructor.", + ) + + active = param.Boolean( + default=True, + doc="Whether additional steps can be automatically appended to the ChatSteps." + ) css_classes = param.List( default=["chat-steps"], doc="CSS classes to apply to the component.", ) + _rename = {"step_params": None, "active": None, **Column._rename} + + _stylesheets = [f"{CDN_DIST}css/chat_steps.css"] + @param.depends("objects", watch=True, on_init=True) def _validate_steps(self): for step in self.objects: if not isinstance(step, ChatStep): raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - def append_step(self, objects: str | list[str] | None = None, **step_params): + def create_step(self, objects: str | list[str] | None = None, **step_params): """ Create a new ChatStep and append it to the ChatSteps. Arguments --------- + objects : str | list[str] | None + The initial object or objects to append to the ChatStep. **step_params : dict Parameters to pass to the ChatStep constructor. @@ -280,11 +295,14 @@ def append_step(self, objects: str | list[str] | None = None, **step_params): ChatStep The newly created ChatStep. """ + merged_step_params = self.step_params.copy() if objects is not None: if not isinstance(objects, list): objects = [objects] - step_params["objects"] = objects - step = ChatStep(**step_params) + objects = [Markdown(obj, css_classes=["step-message"]) if isinstance(obj, str) else obj for obj in objects] + step_params["objects"] = objects + merged_step_params.update(step_params) + step = ChatStep(**merged_step_params) self.append(step) return step @@ -315,3 +333,10 @@ def serialize( prefix_with_viewable_label=prefix_with_viewable_label, prefix_with_container_label=prefix_with_container_label, ) + + def __enter__(self): + self.active = True + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.active = False diff --git a/panel/dist/css/chat_steps.css b/panel/dist/css/chat_steps.css index a1d2f81aec..7da1904bc4 100644 --- a/panel/dist/css/chat_steps.css +++ b/panel/dist/css/chat_steps.css @@ -1,4 +1,4 @@ -.chat-steps { +:host { padding-block: 0px; - padding-inline: 15px; + max-width: calc(100% - 30px); } From 3b9f27638ccd5340a50a21f38b949b31b8520286 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 23 May 2024 13:57:00 -0700 Subject: [PATCH 16/30] renames and refactor --- examples/reference/chat/ChatFeed.ipynb | 4 +- examples/reference/chat/ChatStep.ipynb | 8 +- panel/chat/__init__.py | 4 +- panel/chat/feed.py | 20 ++-- panel/chat/step.py | 157 ++++--------------------- panel/chat/steps.py | 106 +++++++++++++++++ panel/dist/css/chat_step.css | 2 +- panel/tests/chat/test_feed.py | 23 +++- panel/tests/chat/test_step.py | 63 ++++++++++ panel/tests/chat/test_steps.py | 33 ++++++ 10 files changed, 267 insertions(+), 153 deletions(-) create mode 100644 panel/chat/steps.py create mode 100644 panel/tests/chat/test_step.py create mode 100644 panel/tests/chat/test_steps.py diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 9d193887e2..fd0e626425 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -603,7 +603,7 @@ "source": [ "#### Steps\n", "\n", - "Intermediate steps, like chain of thoughts, can be provided through a series of [`ChatStep`](ChatStep.ipynb), which is conveniently accessible through `stream_steps` and `append_step`." + "Intermediate steps, like chain of thoughts, can be provided through a series of [`ChatStep`](ChatStep.ipynb), which is conveniently accessible through `create_steps` and `append_step`." ] }, { @@ -622,7 +622,7 @@ "metadata": {}, "outputs": [], "source": [ - "steps = chat_feed.stream_steps()\n", + "steps = chat_feed.create_steps()\n", "with steps.append_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\", status=\"running\") as step:\n", " step.stream(\"\\n\\n...Okay the plan is to demo this!\")" ] diff --git a/examples/reference/chat/ChatStep.ipynb b/examples/reference/chat/ChatStep.ipynb index 3fa542a182..294974ef5a 100644 --- a/examples/reference/chat/ChatStep.ipynb +++ b/examples/reference/chat/ChatStep.ipynb @@ -25,8 +25,8 @@ "\n", "##### Core\n", "\n", - "* **`collapse_on_completed`** (`bool`): Whether to collapse the card on completion. Defaults to `True`.\n", - "* **`completed_title`** (`str`): Title to display when status is completed; if not provided and `collapse_on_completed` uses the last object's string. Defaults to `None`.\n", + "* **`collapsed_on_completed`** (`bool`): Whether to collapse the card on completion. Defaults to `True`.\n", + "* **`completed_title`** (`str`): Title to display when status is completed; if not provided and `collapsed_on_completed` uses the last object's string. Defaults to `None`.\n", "* **`default_title`** (`str`): The default title to display if the other title params are unset. Defaults to an empty string `\"\"`.\n", "* **`failed_title`** (`str`): Title to display when status is failed. Defaults to `None`.\n", "* **`margin`** (`tuple`): Allows creating additional space around the component. May be specified as a two-tuple of the form (vertical, horizontal) or a four-tuple (top, right, bottom, left). Defaults to `(5, 5, 5, 10)`.\n", @@ -243,7 +243,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "To prevent the title from inheriting the last `str` object as the title, set `collapse_on_completed=False`." + "To prevent the title from inheriting the last `str` object as the title, set `collapsed_on_completed=False`." ] }, { @@ -252,7 +252,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\", collapse_on_completed=False)\n", + "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\", collapsed_on_completed=False)\n", "chat_step.stream(\"Pretending to do something.\")\n", "chat_step.status = \"completed\"\n", "chat_step" diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index 7e48a71ce1..848ce74620 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -36,7 +36,8 @@ from .input import ChatAreaInput # noqa from .interface import ChatInterface # noqa from .message import ChatMessage # noqa -from .step import ChatStep, ChatSteps # noqa +from .step import ChatStep # noqa +from .steps import ChatSteps # noqa def __getattr__(name): @@ -55,5 +56,6 @@ def __getattr__(name): "ChatMessage", "ChatReactionIcons", "ChatStep", + "ChatSteps", "langchain", ) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 6e8ae3c5ec..f44ad58ff1 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -28,7 +28,8 @@ from ..pane.image import SVG from .icon import ChatReactionIcons from .message import ChatMessage -from .step import ChatSteps +from .step import ChatStep +from .steps import ChatSteps if TYPE_CHECKING: from bokeh.document import Document @@ -682,14 +683,14 @@ def stream( self.param.trigger("_post_hook_trigger") return message - def stream_steps( + def create_steps( self, user: str | None = None, avatar: str | bytes | BytesIO | None = None, - **params - ): + **steps_params + ) -> ChatSteps: """ - Creates a new ChatSteps component and streams it. + Creates a new ChatSteps component and streams it to the logs. Arguments --------- @@ -699,20 +700,21 @@ def stream_steps( avatar : str | bytes | BytesIO | None The avatar to use; overrides the message's avatar if provided. Will default to the avatar parameter. - params : dict + steps_params : dict Parameters to pass to the ChatSteps. Returns ------- The ChatSteps that was created. """ - steps = ChatSteps(**params) + steps = ChatSteps(**steps_params) self.stream(steps, user=user, avatar=avatar) return steps - def stream_step(self, objects: str | list[str] | None = None, **step_params): + def attach_step(self, objects: str | list[str] | None = None, **step_params) -> ChatStep: """ - Streams a step to the latest, active ChatSteps component. + Attaches a step to the latest, active ChatSteps component. + Usually called after `create_steps`. Arguments --------- diff --git a/panel/chat/step.py b/panel/chat/step.py index d1ff1ccc8b..8fbce6f30d 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -5,7 +5,7 @@ import param from ..io.resources import CDN_DIST -from ..layout import Card, Column, Row +from ..layout import Card, Row from ..pane.image import ImageBase from ..pane.markup import HTML, HTMLBasePane, Markdown from ..pane.placeholder import Placeholder @@ -17,7 +17,7 @@ DEFAULT_STATUS_AVATARS = { "pending": BooleanStatus(value=False, margin=0, color="primary"), "running": BooleanStatus(value=True, margin=0, color="warning"), - "completed": BooleanStatus(value=True, margin=0, color="success"), + "success": BooleanStatus(value=True, margin=0, color="success"), "failed": BooleanStatus(value=True, margin=0, color="danger"), } @@ -27,12 +27,12 @@ class ChatStep(Card): default=False, doc="Whether the contents of the Card are collapsed.") - collapse_on_completed = param.Boolean( + collapsed_on_success = param.Boolean( default=True, doc="Whether to collapse the card on completion.") - completed_title = param.String(default=None, doc=""" - Title to display when status is completed; if not provided and collapse_on_completed + success_title = param.String(default=None, doc=""" + Title to display when status is success; if not provided and collapsed_on_success uses the last object's string.""") default_avatars = param.Dict( @@ -64,19 +64,19 @@ class ChatStep(Card): ) status = param.Selector( - default="pending", objects=["pending", "running", "completed", "failed"]) + default="pending", objects=["pending", "running", "success", "failed"]) title = param.String(default="", constant=True, doc=""" The title of the chat step. Will redirect to default_title on init. After, it cannot be set directly; instead use the *_title params.""") _rename: ClassVar[Mapping[str, str | None]] = { - "collapse_on_completed": None, + "collapsed_on_success": None, "default_avatars": None, "default_title": None, "pending_title": None, "running_title": None, - "completed_title": None, + "success_title": None, "failed_title": None, "status": None, **Card._rename, @@ -117,7 +117,7 @@ def __exit__(self, exc_type, exc_value, traceback): # convert to str to wrap repr in apostrophes self.failed_title = f"Failed due to: {str(exc_value)!r}" raise exc_value - self.status = "completed" + self.status = "success" @param.depends("status", watch=True) def _render_avatar(self): @@ -138,46 +138,28 @@ def _render_avatar(self): "default_title", "pending_title", "running_title", - "completed_title", + "success_title", "failed_title", watch=True, on_init=True, ) def _update_title_on_status(self): + if self.status == "pending" and self.pending_title is not None: + title = self.pending_title + elif self.status == "running" and self.running_title is not None: + title = self.running_title + elif self.status == "success" and self.success_title is not None: + title = self.success_title + elif self.status == "failed" and self.failed_title is not None: + title = self.failed_title + else: + title = self.default_title with param.edit_constant(self): - if self.status == "pending" and self.pending_title is not None: - self.title = self.pending_title - - elif self.status == "running" and self.running_title is not None: - self.title = self.running_title - - elif self.status == "completed": - if self.completed_title: - self.title = self.completed_title - elif self.objects and self.collapse_on_completed: - obj = self.objects[-1] - for _ in range(100): - if hasattr(obj, "objects"): - obj = obj.objects[-1] - else: - break - - if hasattr(obj, "object"): - obj = obj.object - - if isinstance(obj, str): - self.title = obj - else: - self.title = self.default_title - elif self.status == "failed" and self.failed_title is not None: - self.title = self.failed_title - - else: - self.title = self.default_title - - @param.depends("status", "collapse_on_completed", watch=True) + self.title = title + + @param.depends("status", "collapsed_on_success", watch=True) def _update_collapsed(self): - if self.status == "completed" and self.collapse_on_completed: + if self.status == "success" and self.collapsed_on_success: self.collapsed = True def stream_title(self, token: str, replace: bool = False): @@ -249,94 +231,3 @@ def serialize( def __str__(self): return self.serialize() - - -class ChatSteps(Column): - - - step_params = param.Dict( - default={}, - doc="Parameters to pass to the ChatStep constructor.", - ) - - active = param.Boolean( - default=True, - doc="Whether additional steps can be automatically appended to the ChatSteps." - ) - - css_classes = param.List( - default=["chat-steps"], - doc="CSS classes to apply to the component.", - ) - - _rename = {"step_params": None, "active": None, **Column._rename} - - _stylesheets = [f"{CDN_DIST}css/chat_steps.css"] - - @param.depends("objects", watch=True, on_init=True) - def _validate_steps(self): - for step in self.objects: - if not isinstance(step, ChatStep): - raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - - def create_step(self, objects: str | list[str] | None = None, **step_params): - """ - Create a new ChatStep and append it to the ChatSteps. - - Arguments - --------- - objects : str | list[str] | None - The initial object or objects to append to the ChatStep. - **step_params : dict - Parameters to pass to the ChatStep constructor. - - Returns - ------- - ChatStep - The newly created ChatStep. - """ - merged_step_params = self.step_params.copy() - if objects is not None: - if not isinstance(objects, list): - objects = [objects] - objects = [Markdown(obj, css_classes=["step-message"]) if isinstance(obj, str) else obj for obj in objects] - step_params["objects"] = objects - merged_step_params.update(step_params) - step = ChatStep(**merged_step_params) - self.append(step) - return step - - def serialize( - self, - prefix_with_viewable_label: bool = True, - prefix_with_container_label: bool = True, - ) -> str: - """ - Format the objects to a string. - - Arguments - --------- - prefix_with_viewable_label : bool - Whether to include the name of the Viewable, or type - of the viewable if no name is specified. - prefix_with_container_label : bool - Whether to include the name of the container, or type - of the container if no name is specified. - - Returns - ------- - str - The serialized string. - """ - return serialize_recursively( - self, - prefix_with_viewable_label=prefix_with_viewable_label, - prefix_with_container_label=prefix_with_container_label, - ) - - def __enter__(self): - self.active = True - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.active = False diff --git a/panel/chat/steps.py b/panel/chat/steps.py new file mode 100644 index 0000000000..d368618dae --- /dev/null +++ b/panel/chat/steps.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import param + +from ..io.resources import CDN_DIST +from ..layout import Column +from ..pane.markup import Markdown +from .step import ChatStep +from .utils import serialize_recursively + + +class ChatSteps(Column): + + step_params = param.Dict( + default={}, + doc="Parameters to pass to the ChatStep constructor.", + ) + + active = param.Boolean( + default=True, + doc="Whether additional steps can be automatically appended to the ChatSteps.", + ) + + css_classes = param.List( + default=["chat-steps"], + doc="CSS classes to apply to the component.", + ) + + _rename = {"step_params": None, "active": None, **Column._rename} + + _stylesheets = [f"{CDN_DIST}css/chat_steps.css"] + + @param.depends("objects", watch=True, on_init=True) + def _validate_steps(self): + for step in self.objects: + if not isinstance(step, ChatStep): + raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") + + def create_step(self, objects: str | list[str] | None = None, **step_params): + """ + Create a new ChatStep and append it to the ChatSteps. + + Arguments + --------- + objects : str | list[str] | None + The initial object or objects to append to the ChatStep. + **step_params : dict + Parameters to pass to the ChatStep constructor. + + Returns + ------- + ChatStep + The newly created ChatStep. + """ + merged_step_params = self.step_params.copy() + if objects is not None: + if not isinstance(objects, list): + objects = [objects] + objects = [ + ( + Markdown(obj, css_classes=["step-message"]) + if isinstance(obj, str) + else obj + ) + for obj in objects + ] + step_params["objects"] = objects + merged_step_params.update(step_params) + step = ChatStep(**merged_step_params) + self.append(step) + return step + + def serialize( + self, + prefix_with_viewable_label: bool = True, + prefix_with_container_label: bool = True, + ) -> str: + """ + Format the objects to a string. + + Arguments + --------- + prefix_with_viewable_label : bool + Whether to include the name of the Viewable, or type + of the viewable if no name is specified. + prefix_with_container_label : bool + Whether to include the name of the container, or type + of the container if no name is specified. + + Returns + ------- + str + The serialized string. + """ + return serialize_recursively( + self, + prefix_with_viewable_label=prefix_with_viewable_label, + prefix_with_container_label=prefix_with_container_label, + ) + + def __enter__(self): + self.active = True + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.active = False diff --git a/panel/dist/css/chat_step.css b/panel/dist/css/chat_step.css index d0f336dab7..5b718dec13 100644 --- a/panel/dist/css/chat_step.css +++ b/panel/dist/css/chat_step.css @@ -29,7 +29,7 @@ width: 15px; height: 15px; margin-inline: 5px; - margin-block: 3px; + margin-block: 5px; } .step-avatar { diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index c64497140d..46aef3afc7 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -6,7 +6,8 @@ from panel.chat.feed import ChatFeed from panel.chat.icon import ChatReactionIcons from panel.chat.message import DEFAULT_AVATARS, ChatMessage -from panel.chat.step import ChatStep, ChatSteps +from panel.chat.step import ChatStep +from panel.chat.steps import ChatSteps from panel.chat.utils import avatar_lookup from panel.layout import Column, Row from panel.pane.image import Image @@ -190,13 +191,29 @@ def test_stream(self, chat_feed): assert chat_feed.objects[1] is new_entry assert chat_feed.objects[1].object == "New message" - def test_stream_steps(self, chat_feed): + def test_create_steps(self, chat_feed): chat_step = ChatStep(title="Testing...") - chat_steps = chat_feed.stream_steps(objects=[chat_step]) + chat_steps = chat_feed.create_steps(objects=[chat_step]) assert isinstance(chat_steps, ChatSteps) assert chat_steps == chat_feed[0].object assert chat_steps[0] == chat_step + def test_attach_step(self, chat_feed): + chat_step = ChatStep(title="Testing...") + with chat_feed.create_steps(objects=[chat_step]) as chat_steps: + chat_feed.attach_step("Hello", title="Step 1") + chat_feed.attach_step("Hey", title="Step 2") + assert len(chat_steps) == 3 + assert chat_steps[0].objects == [] + assert chat_steps[1].objects[0].object == "Hello" + assert chat_steps[1].objects[0].css_classes == ["step-message"] + assert chat_steps[2].objects[0].object == "Hey" + assert chat_steps[2].objects[0].css_classes == ["step-message"] + + assert chat_steps[0].title == "Testing..." + assert chat_steps[1].title == "Step 1" + assert chat_steps[2].title == "Step 2" + def test_stream_with_user_avatar(self, chat_feed): user = "Bob" avatar = "👨" diff --git a/panel/tests/chat/test_step.py b/panel/tests/chat/test_step.py new file mode 100644 index 0000000000..880db6ab80 --- /dev/null +++ b/panel/tests/chat/test_step.py @@ -0,0 +1,63 @@ +import pytest + +from panel.chat.step import ChatStep + + +class TestChatStep: + def test_initial_status(self): + step = ChatStep() + assert step.status == "pending", "Initial status should be 'pending'" + + def test_status_transitions_and_titles(self): + step = ChatStep(running_title="Running", pending_title="Pending", success_title="Success", failed_title="Error Occurred") + assert step.status == "pending", "Initial status should be 'pending'" + + # Test running status + with step: + assert step.status == "running", "Status should be 'running' during context execution" + assert step.title == "Running", "Title should be 'Running' during context execution" + + # Test success status + assert step.status == "success", "Status should be 'success' after context execution without errors" + assert step.title == "Success", "Title should be 'Success' after context execution without errors" + + # Test failed status + with pytest.raises(ValueError): + with step: + raise ValueError("Error") + assert step.status == "failed", "Status should be 'failed' after an exception" + assert step.title == "Error Occurred", "Title should update to 'Error Occurred' on failure" + + def test_avatar_and_collapsed_behavior(self): + step = ChatStep(collapsed_on_success=True) + initial_avatar = step._avatar_placeholder.object + + # Test avatar updates + with step: + assert step._avatar_placeholder.object != initial_avatar, "Avatar should change when status is 'running'" + assert step._avatar_placeholder.object != initial_avatar, "Avatar should change when status is 'success'" + + # Test collapsed behavior + assert step.collapsed, "Step should be collapsed on success when collapsed_on_success is True" + + def test_streaming_updates(self): + step = ChatStep() + + # Test title streaming + step.stream_title("New Title", replace=True) + assert step.title == "New Title", "Title should be replaced when streaming new title with replace=True" + step.stream_title(" Appended", replace=False) + assert step.title == "New Title Appended", "Title should be appended when streaming with replace=False" + + # Test content streaming + step.stream("First message") + assert len(step.objects) == 1, "First message should be added to objects" + assert step.objects[0].object == "First message", "First message should be 'First message'" + step.stream("Second message") + assert len(step.objects) == 1 + assert step.objects[0].object == "First messageSecond message", "Messages should be concatenated" + + def test_serialization(self): + step = ChatStep() + serialized = step.serialize() + assert isinstance(serialized, str), "Serialization should return a string representation" diff --git a/panel/tests/chat/test_steps.py b/panel/tests/chat/test_steps.py new file mode 100644 index 0000000000..131ddeb686 --- /dev/null +++ b/panel/tests/chat/test_steps.py @@ -0,0 +1,33 @@ +import pytest + +from panel.chat.steps import ChatStep, ChatSteps +from panel.pane.markup import Markdown + + +class TestChatSteps: + def test_create_step(self): + chat_steps = ChatSteps() + chat_step = chat_steps.create_step("Hello World") + assert isinstance(chat_step, ChatStep) + assert len(chat_steps.objects) == 1 + assert chat_steps.objects[0] == chat_step + assert isinstance(chat_steps.objects[0].objects[0], Markdown) + assert chat_steps.objects[0].objects[0].object == "Hello World" + + def test_validate_steps_with_invalid_step(self): + chat_steps = ChatSteps() + chat_steps.objects.append("Not a ChatStep") + with pytest.raises(ValueError): + chat_steps._validate_steps() + + def test_active_state_management(self): + chat_steps = ChatSteps() + with chat_steps: + assert chat_steps.active is True + assert chat_steps.active is False + + def test_serialization(self): + chat_steps = ChatSteps() + chat_steps.create_step("Test Serialization") + serialized_data = chat_steps.serialize() + assert "Test Serialization" in serialized_data From d4468bb9037bdab51952f4d48da3532378e429c5 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 23 May 2024 14:15:16 -0700 Subject: [PATCH 17/30] Update docs --- examples/reference/chat/ChatFeed.ipynb | 23 +++- examples/reference/chat/ChatStep.ipynb | 44 ++----- examples/reference/chat/ChatSteps.ipynb | 150 ++++++++++++++++++++++++ panel/chat/feed.py | 2 +- panel/chat/step.py | 14 ++- panel/chat/steps.py | 7 +- 6 files changed, 199 insertions(+), 41 deletions(-) create mode 100644 examples/reference/chat/ChatSteps.ipynb diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index fd0e626425..8a31b31693 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -623,7 +623,7 @@ "outputs": [], "source": [ "steps = chat_feed.create_steps()\n", - "with steps.append_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\", status=\"running\") as step:\n", + "with steps.attach_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\") as step:\n", " step.stream(\"\\n\\n...Okay the plan is to demo this!\")" ] }, @@ -640,9 +640,24 @@ "metadata": {}, "outputs": [], "source": [ - "steps.append_step(title=\"The next thing is to come up with features to demo...\", status=\"pending\")\n", - "steps.append_step(title=\"TBD\", status=\"pending\")\n", - "steps.append_step(title=\"TBD\", status=\"pending\")" + "chat_feed.attach_step(title=\"The next thing is to come up with features to demo...\", status=\"pending\")\n", + "chat_feed.attach_step(title=\"TBD\", status=\"pending\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Attaching a step can also be conveniently accessed through the `ChatFeed`, which will select the last active `ChatSteps` to attach to." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_feed.attach_step(title=\"Demo features\", status=\"pending\")" ] }, { diff --git a/examples/reference/chat/ChatStep.ipynb b/examples/reference/chat/ChatStep.ipynb index 294974ef5a..d2b9101c8f 100644 --- a/examples/reference/chat/ChatStep.ipynb +++ b/examples/reference/chat/ChatStep.ipynb @@ -25,15 +25,15 @@ "\n", "##### Core\n", "\n", - "* **`collapsed_on_completed`** (`bool`): Whether to collapse the card on completion. Defaults to `True`.\n", - "* **`completed_title`** (`str`): Title to display when status is completed; if not provided and `collapsed_on_completed` uses the last object's string. Defaults to `None`.\n", + "* **`collapsed_on_success`** (`bool`): Whether to collapse the card on completion. Defaults to `True`.\n", + "* **`success_title`** (`str`): Title to display when status is success; if not provided and `collapsed_on_success` uses the last object's string. Defaults to `None`.\n", "* **`default_title`** (`str`): The default title to display if the other title params are unset. Defaults to an empty string `\"\"`.\n", "* **`failed_title`** (`str`): Title to display when status is failed. Defaults to `None`.\n", "* **`margin`** (`tuple`): Allows creating additional space around the component. May be specified as a two-tuple of the form (vertical, horizontal) or a four-tuple (top, right, bottom, left). Defaults to `(5, 5, 5, 10)`.\n", "* **``objects``** (list): The list of objects to display in the `ChatStep`, which will be formatted like a `Column`. Should not generally be modified directly except when replaced in its entirety.\n", "* **`pending_title`** (`str`): Title to display when status is pending. Defaults to `None`.\n", "* **`running_title`** (`str`): Title to display when status is running. Defaults to `None`.\n", - "* **`status`** (`str`): The status of the chat step. Must be one of [\"pending\", \"running\", \"completed\", \"failed\"]. Defaults to `\"pending\"`.\n", + "* **`status`** (`str`): The status of the chat step. Must be one of [\"pending\", \"running\", \"success\", \"failed\"]. Defaults to `\"pending\"`.\n", "\n", "##### Styling\n", "\n", @@ -46,6 +46,7 @@ "\n", "* **`stream`**: Stream a token to the last available string-like object.\n", "* **`stream_title`**: Stream a token to the title header.\n", + "* **`serialize`**: Format the object to a string.\n", "\n", "___" ] @@ -190,10 +191,10 @@ " default_avatars={\n", " \"pending\": \"🤔\",\n", " \"running\": \"🏃\",\n", - " \"completed\": \"🎉\",\n", + " \"success\": \"🎉\",\n", " \"failed\": \"😞\",\n", " },\n", - " status=\"completed\",\n", + " status=\"success\",\n", ")\n", "chat_step" ] @@ -227,7 +228,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Upon completion, set the status to `completed` and it'll automatically collapse the contents and use the last `str` object as the title." + "Upon completion, set the status to `success` and it'll automatically collapse the contents." ] }, { @@ -236,14 +237,14 @@ "metadata": {}, "outputs": [], "source": [ - "chat_step.status = \"completed\"" + "chat_step.status = \"success\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "To prevent the title from inheriting the last `str` object as the title, set `collapsed_on_completed=False`." + "To have the title update on success, either provide the `success_title` or `default_title`." ] }, { @@ -252,28 +253,9 @@ "metadata": {}, "outputs": [], "source": [ - "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\", collapsed_on_completed=False)\n", + "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\", success_title=\"Pretend job done!\")\n", "chat_step.stream(\"Pretending to do something.\")\n", - "chat_step.status = \"completed\"\n", - "chat_step" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Alternatively, provide the completed title." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "chat_step = pn.chat.ChatStep(status=\"running\", running_title=\"Processing this step...\", completed_title=\"Pretend job done!\")\n", - "chat_step.stream(\"Pretending to do something.\")\n", - "chat_step.status = \"completed\"\n", + "chat_step.status = \"success\"\n", "chat_step" ] }, @@ -294,7 +276,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_step = pn.chat.ChatStep(running_title=\"Processing this step...\", completed_title=\"Pretend job done!\")\n", + "chat_step = pn.chat.ChatStep(running_title=\"Processing this step...\", success_title=\"Pretend job done!\")\n", "with chat_step:\n", " chat_step.stream(\"Pretending to do something.\")\n", "\n", @@ -314,7 +296,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_step = pn.chat.ChatStep(running_title=\"Processing this step...\", completed_title=\"Pretend job done!\")\n", + "chat_step = pn.chat.ChatStep(running_title=\"Processing this step...\", success_title=\"Pretend job done!\")\n", "chat_step" ] }, diff --git a/examples/reference/chat/ChatSteps.ipynb b/examples/reference/chat/ChatSteps.ipynb new file mode 100644 index 0000000000..472b94a3b8 --- /dev/null +++ b/examples/reference/chat/ChatSteps.ipynb @@ -0,0 +1,150 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `ChatSteps` component is a specialized `Column`, particularly used for holding and adding [`ChatStep`](ChatStep.ipynb).\n", + "\n", + "Check out the [panel-chat-examples](https://holoviz-topics.github.io/panel-chat-examples/) docs to see applicable examples related to [LangChain](https://python.langchain.com/docs/get_started/introduction), [OpenAI](https://openai.com/blog/chatgpt), [Mistral](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwjZtP35yvSBAxU00wIHHerUDZAQFnoECBEQAQ&url=https%3A%2F%2Fdocs.mistral.ai%2F&usg=AOvVaw2qpx09O_zOzSksgjBKiJY_&opi=89978449), [Llama](https://ai.meta.com/llama/), etc. If you have an example to demo, we'd love to add it to the panel-chat-examples gallery!\n", + "\n", + "#### Parameters:\n", + "\n", + "##### Core\n", + "\n", + "* **`step_params`** (`dict`): Parameters to pass to the ChatStep constructor. Defaults to `{}`.\n", + "* **`active`** (`bool`): Whether additional steps can be appended to the ChatSteps. Defaults to `True`.\n", + "\n", + "___" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Basics" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`ChatSteps` can be initialized without any arguments." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_steps = pn.chat.ChatSteps()\n", + "chat_steps" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To add a step, you can add it manually." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_steps.objects = [pn.chat.ChatStep(objects=[\"Content\"], title=\"This is a step\")]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Or more conveniently, through the `attach_step` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_steps.attach_step(\"New Content\", title=\"This is a new step\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To convert the objects into a string, call `serialize`, or simply use `str()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_steps.serialize()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`ChatSteps` can also be used as a context manager, which will make it active." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with chat_steps:\n", + " chat_steps.attach_step(\"Newest Content\", title=\"This is the newest step\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When inactive, steps cannot be added to it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with chat_steps:\n", + " ...\n", + "chat_steps.attach_step()" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/panel/chat/feed.py b/panel/chat/feed.py index f44ad58ff1..d41ef9fbea 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -728,7 +728,7 @@ def attach_step(self, objects: str | list[str] | None = None, **step_params) -> if isinstance(obj, ChatSteps): if not obj.active: raise ValueError("Cannot stream a step to an inactive ChatSteps component") - return obj.create_step(objects, **step_params) + return obj.attach_step(objects, **step_params) else: raise ValueError("No active ChatSteps component found") diff --git a/panel/chat/step.py b/panel/chat/step.py index 8fbce6f30d..9c59a078b5 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -32,12 +32,11 @@ class ChatStep(Card): doc="Whether to collapse the card on completion.") success_title = param.String(default=None, doc=""" - Title to display when status is success; if not provided and collapsed_on_success - uses the last object's string.""") + Title to display when status is success.""") default_avatars = param.Dict( default=DEFAULT_STATUS_AVATARS, - doc="Mapping from status to default status avatar") + doc="Mapping from status to default status avatar.") default_title = param.String( default="", @@ -119,11 +118,18 @@ def __exit__(self, exc_type, exc_value, traceback): raise exc_value self.status = "success" - @param.depends("status", watch=True) + @param.depends("status", "default_avatars", watch=True) def _render_avatar(self): """ Render the avatar pane as some HTML text or Image pane. """ + extra_keys = set(self.default_avatars.keys()) - set(DEFAULT_STATUS_AVATARS.keys()) + if extra_keys: + raise ValueError( + f"Invalid status avatars. Must be one of 'pending', 'running', 'success', 'failed'; " + f"got {extra_keys}." + ) + avatar = avatar_lookup( self.status, None, diff --git a/panel/chat/steps.py b/panel/chat/steps.py index d368618dae..d54117de7e 100644 --- a/panel/chat/steps.py +++ b/panel/chat/steps.py @@ -36,7 +36,7 @@ def _validate_steps(self): if not isinstance(step, ChatStep): raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - def create_step(self, objects: str | list[str] | None = None, **step_params): + def attach_step(self, objects: str | list[str] | None = None, **step_params): """ Create a new ChatStep and append it to the ChatSteps. @@ -52,6 +52,11 @@ def create_step(self, objects: str | list[str] | None = None, **step_params): ChatStep The newly created ChatStep. """ + if not self.active: + raise ValueError( + "Cannot attach a step when the ChatSteps is not active." + ) + merged_step_params = self.step_params.copy() if objects is not None: if not isinstance(objects, list): From cb69745a057705c80da57b8086c4118e25cfa073 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 30 May 2024 09:03:46 -0700 Subject: [PATCH 18/30] Fix test --- panel/tests/chat/test_steps.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/panel/tests/chat/test_steps.py b/panel/tests/chat/test_steps.py index 131ddeb686..c33beeec87 100644 --- a/panel/tests/chat/test_steps.py +++ b/panel/tests/chat/test_steps.py @@ -5,9 +5,9 @@ class TestChatSteps: - def test_create_step(self): + def test_attach_step(self): chat_steps = ChatSteps() - chat_step = chat_steps.create_step("Hello World") + chat_step = chat_steps.attach_step("Hello World") assert isinstance(chat_step, ChatStep) assert len(chat_steps.objects) == 1 assert chat_steps.objects[0] == chat_step @@ -28,6 +28,6 @@ def test_active_state_management(self): def test_serialization(self): chat_steps = ChatSteps() - chat_steps.create_step("Test Serialization") + chat_steps.attach_step("Test Serialization") serialized_data = chat_steps.serialize() assert "Test Serialization" in serialized_data From f8143dfc4a1395020d3ceeb40616d609c461fb15 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Thu, 30 May 2024 09:17:34 -0700 Subject: [PATCH 19/30] fix the tests --- examples/reference/chat/ChatStep.ipynb | 9 ++++++--- examples/reference/chat/ChatSteps.ipynb | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/examples/reference/chat/ChatStep.ipynb b/examples/reference/chat/ChatStep.ipynb index d2b9101c8f..a6a9af9dbb 100644 --- a/examples/reference/chat/ChatStep.ipynb +++ b/examples/reference/chat/ChatStep.ipynb @@ -306,9 +306,12 @@ "metadata": {}, "outputs": [], "source": [ - "with chat_step:\n", - " chat_step.stream(\"Breaking something\")\n", - " raise RuntimeError(\"Just demoing!\")" + "try:\n", + " with chat_step:\n", + " chat_step.stream(\"Breaking something\")\n", + " raise RuntimeError(\"Just demoing!\")\n", + "except RuntimeError as e:\n", + " print(\"Comment this try/except to see what happens!\")" ] }, { diff --git a/examples/reference/chat/ChatSteps.ipynb b/examples/reference/chat/ChatSteps.ipynb index 472b94a3b8..4aa1b328b4 100644 --- a/examples/reference/chat/ChatSteps.ipynb +++ b/examples/reference/chat/ChatSteps.ipynb @@ -135,7 +135,11 @@ "source": [ "with chat_steps:\n", " ...\n", - "chat_steps.attach_step()" + "\n", + "try:\n", + " chat_steps.attach_step()\n", + "except ValueError:\n", + " print(\"Cannot attach a step when the ChatSteps is not active.\")" ] } ], From d8db0b92cbb2c146c7bc63b3415df70641eb2ee0 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 3 Jun 2024 13:48:04 -0700 Subject: [PATCH 20/30] address minor comments --- panel/chat/step.py | 64 +++++++++++++++++++++++------------ panel/tests/chat/test_step.py | 41 ++++++++++++++++++---- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/panel/chat/step.py b/panel/chat/step.py index 9c59a078b5..7d6eed7836 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import ClassVar, Mapping +from typing import ClassVar, Literal, Mapping import param @@ -9,12 +9,13 @@ from ..pane.image import ImageBase from ..pane.markup import HTML, HTMLBasePane, Markdown from ..pane.placeholder import Placeholder +from ..util import edit_readonly from ..widgets.indicators import BooleanStatus from .utils import ( avatar_lookup, build_avatar_pane, serialize_recursively, stream_to, ) -DEFAULT_STATUS_AVATARS = { +DEFAULT_STATUS_BADGES = { "pending": BooleanStatus(value=False, margin=0, color="primary"), "running": BooleanStatus(value=True, margin=0, color="warning"), "success": BooleanStatus(value=True, margin=0, color="success"), @@ -34,9 +35,11 @@ class ChatStep(Card): success_title = param.String(default=None, doc=""" Title to display when status is success.""") - default_avatars = param.Dict( - default=DEFAULT_STATUS_AVATARS, - doc="Mapping from status to default status avatar.") + default_badges = param.Dict( + default=DEFAULT_STATUS_BADGES, doc=""" + Mapping from status to default status badge; keys must be one of + 'pending', 'running', 'success', 'failed'. + """) default_title = param.String( default="", @@ -46,6 +49,10 @@ class ChatStep(Card): default=None, doc="Title to display when status is failed.") + header = param.Parameter(doc=""" + A Panel component to display in the header bar of the Card. + Will override the given title if defined.""", readonly=True) + margin = param.Parameter( default=(5, 5, 5, 10), doc=""" Allows to create additional space around the component. May @@ -71,7 +78,7 @@ class ChatStep(Card): _rename: ClassVar[Mapping[str, str | None]] = { "collapsed_on_success": None, - "default_avatars": None, + "default_badges": None, "default_title": None, "pending_title": None, "running_title": None, @@ -85,6 +92,7 @@ class ChatStep(Card): def __init__(self, *objects, **params): self._instance = None + self._failed_title = "" # for cases when contextmanager is invoked twice self._avatar_placeholder = Placeholder(css_classes=["step-avatar-container"]) if params.get("title"): @@ -99,31 +107,34 @@ def __init__(self, *objects, **params): margin=0, css_classes=["step-title"], ) - self.header = Row( - self._avatar_placeholder, - self._title_pane, - stylesheets=self._stylesheets + self.param.stylesheets.rx(), - css_classes=["step-header"], - ) + + with edit_readonly(self): + self.header = Row( + self._avatar_placeholder, + self._title_pane, + stylesheets=self._stylesheets + self.param.stylesheets.rx(), + css_classes=["step-header"], + ) + def __enter__(self): self.status = "running" return self def __exit__(self, exc_type, exc_value, traceback): if exc_type is not None: - self.status = "failed" if self.failed_title is None: # convert to str to wrap repr in apostrophes - self.failed_title = f"Failed due to: {str(exc_value)!r}" + self._failed_title = f"Error: {str(exc_value)!r}" + self.status = "failed" raise exc_value self.status = "success" - @param.depends("status", "default_avatars", watch=True) + @param.depends("status", "default_badges", watch=True) def _render_avatar(self): """ Render the avatar pane as some HTML text or Image pane. """ - extra_keys = set(self.default_avatars.keys()) - set(DEFAULT_STATUS_AVATARS.keys()) + extra_keys = set(self.default_badges.keys()) - set(DEFAULT_STATUS_BADGES.keys()) if extra_keys: raise ValueError( f"Invalid status avatars. Must be one of 'pending', 'running', 'success', 'failed'; " @@ -133,8 +144,8 @@ def _render_avatar(self): avatar = avatar_lookup( self.status, None, - self.default_avatars, - DEFAULT_STATUS_AVATARS, + self.default_badges, + DEFAULT_STATUS_BADGES, ) avatar_pane = build_avatar_pane(avatar, ["step-avatar"]) self._avatar_placeholder.update(avatar_pane) @@ -158,8 +169,11 @@ def _update_title_on_status(self): title = self.success_title elif self.status == "failed" and self.failed_title is not None: title = self.failed_title + elif self.status == "failed" and self._failed_title: + title = self._failed_title else: title = self.default_title + with param.edit_constant(self): self.title = title @@ -168,7 +182,12 @@ def _update_collapsed(self): if self.status == "success" and self.collapsed_on_success: self.collapsed = True - def stream_title(self, token: str, replace: bool = False): + def stream_title( + self, + token: str, + status: Literal["pending", "running", "success", "failed", "default"] = "running", + replace: bool = False + ): """ Stream a token to the title header. @@ -176,13 +195,16 @@ def stream_title(self, token: str, replace: bool = False): --------- token : str The token to stream. + status : str + The status title to stream to, one of 'pending', 'running', 'success', 'failed', or "default". replace : bool Whether to replace the existing text. """ if replace: - self.title = token + setattr(self, f"{status}_title", token) else: - self.title += token + original = getattr(self, f"{status}_title") or "" + setattr(self, f"{status}_title", original + token) def stream(self, token: str, replace: bool = False): """ diff --git a/panel/tests/chat/test_step.py b/panel/tests/chat/test_step.py index 880db6ab80..3548a3c4d9 100644 --- a/panel/tests/chat/test_step.py +++ b/panel/tests/chat/test_step.py @@ -43,12 +43,6 @@ def test_avatar_and_collapsed_behavior(self): def test_streaming_updates(self): step = ChatStep() - # Test title streaming - step.stream_title("New Title", replace=True) - assert step.title == "New Title", "Title should be replaced when streaming new title with replace=True" - step.stream_title(" Appended", replace=False) - assert step.title == "New Title Appended", "Title should be appended when streaming with replace=False" - # Test content streaming step.stream("First message") assert len(step.objects) == 1, "First message should be added to objects" @@ -57,7 +51,42 @@ def test_streaming_updates(self): assert len(step.objects) == 1 assert step.objects[0].object == "First messageSecond message", "Messages should be concatenated" + def test_streaming_title_updates(self): + step = ChatStep(running_title="Run") + step.stream_title("Pend", status="pending") + assert step.pending_title == "Pend", "Pending title should be 'Pend'" + assert step.title == "Pend", "Title should be 'Pend' when status is 'pending'" + + step.stream_title("ing", status="pending") + assert step.pending_title == "Pending", "Pending title should be 'Pending'" + assert step.title == "Pending", "Title should be 'Pending' when status is 'pending'" + + step.stream_title("Starting now...", status="pending", replace=True) + assert step.pending_title == "Starting now...", "Pending title should be 'Starting now...'" + assert step.title == "Starting now...", "Title should be 'Starting now...' when status is 'pending'" + + step.stream_title("ning") + assert step.running_title == "Running" + assert step.title == "Starting now...", "Title should be 'Starting now...' when status is 'pending'" + step.status = "running" + assert step.title == "Running", "Title should be 'Running' when status is 'running'" + def test_serialization(self): step = ChatStep() serialized = step.serialize() assert isinstance(serialized, str), "Serialization should return a string representation" + + def test_repeated_error(self): + step = ChatStep() + with pytest.raises(ValueError): + with step: + raise ValueError("Testing") + assert step.status == "failed", "Status should be 'failed' after an exception" + assert step.title == "Error: 'Testing'", "Title should update to 'Error: 'Testing'' on failure" + + with pytest.raises(RuntimeError): + with step: + raise RuntimeError("Second Testing") + + assert step.status == "failed", "Status should be 'failed' after an exception" + assert step.title == "Error: 'Second Testing'", "Title should update to 'Error: 'Second Testing'' on failure again" From 519a765d4ede9226ceb1cbc3497c5a9562f7966b Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 3 Jun 2024 14:59:58 -0700 Subject: [PATCH 21/30] Rename to append_steps, compress into steps kwarg, docs --- examples/reference/chat/ChatFeed.ipynb | 29 +++------ examples/reference/chat/ChatStep.ipynb | 82 +++++++++++++++++++++++-- examples/reference/chat/ChatSteps.ipynb | 9 ++- panel/chat/feed.py | 59 ++++++++---------- panel/chat/steps.py | 2 +- 5 files changed, 122 insertions(+), 59 deletions(-) diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 8a31b31693..07f8584cb1 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -603,7 +603,7 @@ "source": [ "#### Steps\n", "\n", - "Intermediate steps, like chain of thoughts, can be provided through a series of [`ChatStep`](ChatStep.ipynb), which is conveniently accessible through `create_steps` and `append_step`." + "Intermediate steps, like chain of thoughts, can be provided through a series of [`ChatStep`](ChatStep.ipynb), which is conveniently accessible through `append_step`." ] }, { @@ -622,8 +622,7 @@ "metadata": {}, "outputs": [], "source": [ - "steps = chat_feed.create_steps()\n", - "with steps.attach_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\") as step:\n", + "with chat_feed.append_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\") as step:\n", " step.stream(\"\\n\\n...Okay the plan is to demo this!\")" ] }, @@ -631,7 +630,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Multiple steps can be initialized." + "To append to an existing `ChatSteps`, you can specify `steps=\"existing\"`." ] }, { @@ -640,15 +639,15 @@ "metadata": {}, "outputs": [], "source": [ - "chat_feed.attach_step(title=\"The next thing is to come up with features to demo...\", status=\"pending\")\n", - "chat_feed.attach_step(title=\"TBD\", status=\"pending\")" + "step = chat_feed.append_step(title=\"Execute the plan\", steps=\"existing\", status=\"running\")\n", + "step.stream(\"\\n\\n...Executing plan...\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Attaching a step can also be conveniently accessed through the `ChatFeed`, which will select the last active `ChatSteps` to attach to." + "Or pass in an existing `ChatSteps` component." ] }, { @@ -657,24 +656,16 @@ "metadata": {}, "outputs": [], "source": [ - "chat_feed.attach_step(title=\"Demo features\", status=\"pending\")" + "message = chat_feed.objects[-1]\n", + "steps = message.object\n", + "step = chat_feed.append_step(\"Holding off...\", title=\"Clean up\", status=\"pending\", steps=steps)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Then a context manager can be used to \"process\" them." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with steps[1] as step:\n", - " step.stream(\"\\n\\n...Okay, we can demo the context manager\")" + "See [`ChatSteps`](ChatSteps.ipynb) and [`ChatStep`](ChatStep.ipynb) for more details on how to use those components." ] }, { diff --git a/examples/reference/chat/ChatStep.ipynb b/examples/reference/chat/ChatStep.ipynb index a6a9af9dbb..be2d4fd3c7 100644 --- a/examples/reference/chat/ChatStep.ipynb +++ b/examples/reference/chat/ChatStep.ipynb @@ -8,6 +8,7 @@ }, "outputs": [], "source": [ + "import time\n", "import panel as pn\n", "\n", "pn.extension()" @@ -38,7 +39,7 @@ "##### Styling\n", "\n", "* **`collapsed`** (`bool`): Whether the contents of the `ChatStep` are collapsed. Defaults to `False`.\n", - "* **`default_avatars`** (`dict`): Mapping from status to default status avatar. Defaults to `DEFAULT_STATUS_AVATARS`.\n", + "* **`default_badges`** (`dict`): Mapping from status to default status badge. Defaults to `DEFAULT_STATUS_BADGES`; keys must be one of 'pending', 'running', 'success', 'failed'.\n", "\n", "#### Methods\n", "\n", @@ -176,9 +177,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "#### Avatars\n", + "#### Badges\n", "\n", - "The default avatars are `BooleanStatus`, but can be changed by providing `default_avatars`. The values can be emojis, images, text, or Panel objects." + "The default avatars are `BooleanStatus`, but can be changed by providing `default_badges`. The values can be emojis, images, text, or Panel objects." ] }, { @@ -188,7 +189,7 @@ "outputs": [], "source": [ "chat_step = pn.chat.ChatStep(\n", - " default_avatars={\n", + " default_badges={\n", " \"pending\": \"🤔\",\n", " \"running\": \"🏃\",\n", " \"success\": \"🎉\",\n", @@ -314,6 +315,79 @@ " print(\"Comment this try/except to see what happens!\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The title can also be streamed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep()\n", + "chat_step" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.status = \"running\"\n", + "for char in \"It's streaming a title!\":\n", + " time.sleep(0.1)\n", + " chat_step.stream_title(char)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It can be used with any `status`, like setting `status=\"pending\"`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step = pn.chat.ChatStep()\n", + "chat_step" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for char in \"I'm deciding on a title... how about this one?\":\n", + " time.sleep(0.1)\n", + " chat_step.stream_title(char, status=\"pending\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The title can also be completely replaced--equivalent to setting `chat_step.pending_title = \"Nevermind!`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_step.stream_title(\"Nevermind!\", replace=True, status=\"pending\")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/reference/chat/ChatSteps.ipynb b/examples/reference/chat/ChatSteps.ipynb index 4aa1b328b4..f37b02efbd 100644 --- a/examples/reference/chat/ChatSteps.ipynb +++ b/examples/reference/chat/ChatSteps.ipynb @@ -84,7 +84,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_steps.attach_step(\"New Content\", title=\"This is a new step\")" + "chat_steps.append_step(\"New Content\", title=\"This is a new step\")" ] }, { @@ -141,6 +141,13 @@ "except ValueError:\n", " print(\"Cannot attach a step when the ChatSteps is not active.\")" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `ChatSteps` is can be invoked through [`ChatFeed`](ChatFeed.ipynb#Steps) too." + ] } ], "metadata": { diff --git a/panel/chat/feed.py b/panel/chat/feed.py index d41ef9fbea..9e3214b995 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -683,54 +683,45 @@ def stream( self.param.trigger("_post_hook_trigger") return message - def create_steps( + def append_step( self, + objects: str | list[str] | None = None, + steps: Literal["new", "append"] | ChatSteps = "new", user: str | None = None, avatar: str | bytes | BytesIO | None = None, - **steps_params - ) -> ChatSteps: + **step_params + ) -> ChatStep: """ - Creates a new ChatSteps component and streams it to the logs. + Appends a step to a ChatSteps component. Arguments --------- + objects : str | list(str) | None + The objects to stream to the step. + steps : ChatSteps | None + The ChatSteps component to attach the step to. + If "new", creates a new ChatSteps component and streams it to the chat. + If "append", appends the step to the latest active ChatSteps component in the chat. + Else, pass the ChatSteps component directly. user : str | None The user to stream as; overrides the message's user if provided. - Will default to the user parameter. + Will default to the user parameter. Only applicable if steps is "new". avatar : str | bytes | BytesIO | None The avatar to use; overrides the message's avatar if provided. - Will default to the avatar parameter. - steps_params : dict - Parameters to pass to the ChatSteps. - - Returns - ------- - The ChatSteps that was created. - """ - steps = ChatSteps(**steps_params) - self.stream(steps, user=user, avatar=avatar) - return steps - - def attach_step(self, objects: str | list[str] | None = None, **step_params) -> ChatStep: - """ - Attaches a step to the latest, active ChatSteps component. - Usually called after `create_steps`. - - Arguments - --------- - objects : str | list(str) | None - The objects to stream to the step. + Will default to the avatar parameter. Only applicable if steps is "new". step_params : dict Parameters to pass to the ChatStep. """ - for message in reversed(self._chat_log.objects): - obj = message.object - if isinstance(obj, ChatSteps): - if not obj.active: - raise ValueError("Cannot stream a step to an inactive ChatSteps component") - return obj.attach_step(objects, **step_params) - else: - raise ValueError("No active ChatSteps component found") + if steps == "new": + steps = ChatSteps() + self.stream(steps, user=user, avatar=avatar) + elif steps == "existing": + for message in reversed(self._chat_log.objects): + obj = message.object + if isinstance(obj, ChatSteps) and obj.active: + steps = obj + break + return steps.append_step(objects, **step_params) def respond(self): """ diff --git a/panel/chat/steps.py b/panel/chat/steps.py index d54117de7e..60330cc2d3 100644 --- a/panel/chat/steps.py +++ b/panel/chat/steps.py @@ -36,7 +36,7 @@ def _validate_steps(self): if not isinstance(step, ChatStep): raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - def attach_step(self, objects: str | list[str] | None = None, **step_params): + def append_step(self, objects: str | list[str] | None = None, **step_params): """ Create a new ChatStep and append it to the ChatSteps. From 34955d63778f49fb1482d071ab34e5ba256fdfb7 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 3 Jun 2024 15:56:00 -0700 Subject: [PATCH 22/30] Add test --- examples/reference/chat/ChatSteps.ipynb | 6 +-- panel/chat/feed.py | 6 ++- panel/tests/chat/test_feed.py | 50 +++++++++++++++++-------- panel/tests/chat/test_steps.py | 16 +++++--- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/examples/reference/chat/ChatSteps.ipynb b/examples/reference/chat/ChatSteps.ipynb index f37b02efbd..98ba36ba05 100644 --- a/examples/reference/chat/ChatSteps.ipynb +++ b/examples/reference/chat/ChatSteps.ipynb @@ -75,7 +75,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Or more conveniently, through the `attach_step` method." + "Or more conveniently, through the `append_step` method." ] }, { @@ -117,7 +117,7 @@ "outputs": [], "source": [ "with chat_steps:\n", - " chat_steps.attach_step(\"Newest Content\", title=\"This is the newest step\")" + " chat_steps.append_step(\"Newest Content\", title=\"This is the newest step\")" ] }, { @@ -137,7 +137,7 @@ " ...\n", "\n", "try:\n", - " chat_steps.attach_step()\n", + " chat_steps.append_step()\n", "except ValueError:\n", " print(\"Cannot attach a step when the ChatSteps is not active.\")" ] diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 9e3214b995..147c04ef7b 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -715,12 +715,16 @@ def append_step( if steps == "new": steps = ChatSteps() self.stream(steps, user=user, avatar=avatar) - elif steps == "existing": + elif steps == "append": for message in reversed(self._chat_log.objects): obj = message.object if isinstance(obj, ChatSteps) and obj.active: steps = obj break + else: + raise ValueError("No active ChatSteps component found to append the step to.") + elif not isinstance(steps, ChatSteps): + raise ValueError(f"The steps argument must be 'new', 'append', or a ChatSteps type, but got {steps}.") return steps.append_step(objects, **step_params) def respond(self): diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 46aef3afc7..3d3fe0b7e0 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -198,21 +198,41 @@ def test_create_steps(self, chat_feed): assert chat_steps == chat_feed[0].object assert chat_steps[0] == chat_step - def test_attach_step(self, chat_feed): - chat_step = ChatStep(title="Testing...") - with chat_feed.create_steps(objects=[chat_step]) as chat_steps: - chat_feed.attach_step("Hello", title="Step 1") - chat_feed.attach_step("Hey", title="Step 2") - assert len(chat_steps) == 3 - assert chat_steps[0].objects == [] - assert chat_steps[1].objects[0].object == "Hello" - assert chat_steps[1].objects[0].css_classes == ["step-message"] - assert chat_steps[2].objects[0].object == "Hey" - assert chat_steps[2].objects[0].css_classes == ["step-message"] - - assert chat_steps[0].title == "Testing..." - assert chat_steps[1].title == "Step 1" - assert chat_steps[2].title == "Step 2" + def test_append_step(self, chat_feed): + # new + with chat_feed.append_step("Object", title="Title") as step: + assert isinstance(step, ChatStep) + assert step.title == "Title" + assert step.objects[0].object == "Object" + + assert len(chat_feed) == 1 + message = chat_feed.objects[0] + assert isinstance(message, ChatMessage) + + steps = message.object + assert isinstance(steps, ChatSteps) + + assert len(steps) == 1 + assert isinstance(steps[0], ChatStep) + + # existing + with chat_feed.append_step("New Object", title="New Title", steps="append") as step: + assert isinstance(step, ChatStep) + assert step.title == "New Title" + assert step.objects[0].object == "New Object" + assert len(steps) == 2 + assert isinstance(steps[0], ChatStep) + assert isinstance(steps[1], ChatStep) + + # actual component + with chat_feed.append_step("Newest Object", title="Newest Title", steps=steps) as step: + assert isinstance(step, ChatStep) + assert step.title == "Newest Title" + assert step.objects[0].object == "Newest Object" + assert len(steps) == 3 + assert isinstance(steps[0], ChatStep) + assert isinstance(steps[1], ChatStep) + assert isinstance(steps[2], ChatStep) def test_stream_with_user_avatar(self, chat_feed): user = "Bob" diff --git a/panel/tests/chat/test_steps.py b/panel/tests/chat/test_steps.py index c33beeec87..a6051dfe64 100644 --- a/panel/tests/chat/test_steps.py +++ b/panel/tests/chat/test_steps.py @@ -5,20 +5,26 @@ class TestChatSteps: - def test_attach_step(self): + def test_append_step(self): chat_steps = ChatSteps() - chat_step = chat_steps.attach_step("Hello World") + chat_step = chat_steps.append_step("Hello World") assert isinstance(chat_step, ChatStep) assert len(chat_steps.objects) == 1 assert chat_steps.objects[0] == chat_step assert isinstance(chat_steps.objects[0].objects[0], Markdown) assert chat_steps.objects[0].objects[0].object == "Hello World" + def test_append_step_to_inactive(self): + chat_steps = ChatSteps() + with chat_steps: + pass + with pytest.raises(ValueError): + chat_steps.append_step("Hello World") + def test_validate_steps_with_invalid_step(self): chat_steps = ChatSteps() - chat_steps.objects.append("Not a ChatStep") with pytest.raises(ValueError): - chat_steps._validate_steps() + chat_steps.append("Not a ChatStep") def test_active_state_management(self): chat_steps = ChatSteps() @@ -28,6 +34,6 @@ def test_active_state_management(self): def test_serialization(self): chat_steps = ChatSteps() - chat_steps.attach_step("Test Serialization") + chat_steps.append_step("Test Serialization") serialized_data = chat_steps.serialize() assert "Test Serialization" in serialized_data From 00bedc0286812761a9d0b57b7cbd94c80ede6e78 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 3 Jun 2024 16:09:36 -0700 Subject: [PATCH 23/30] remove unused test --- panel/tests/chat/test_feed.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 3d3fe0b7e0..a696a01c06 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -191,13 +191,6 @@ def test_stream(self, chat_feed): assert chat_feed.objects[1] is new_entry assert chat_feed.objects[1].object == "New message" - def test_create_steps(self, chat_feed): - chat_step = ChatStep(title="Testing...") - chat_steps = chat_feed.create_steps(objects=[chat_step]) - assert isinstance(chat_steps, ChatSteps) - assert chat_steps == chat_feed[0].object - assert chat_steps[0] == chat_step - def test_append_step(self, chat_feed): # new with chat_feed.append_step("Object", title="Title") as step: From be7d6c069018dd74aa66b6343b76cbdd0f8ca54a Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Mon, 3 Jun 2024 16:20:35 -0700 Subject: [PATCH 24/30] fix example --- examples/reference/chat/ChatFeed.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 07f8584cb1..faaede8021 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -630,7 +630,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "To append to an existing `ChatSteps`, you can specify `steps=\"existing\"`." + "To append to an existing `ChatSteps`, you can specify `steps=\"append\"` instead of the default `steps=\"new\"`." ] }, { @@ -639,7 +639,7 @@ "metadata": {}, "outputs": [], "source": [ - "step = chat_feed.append_step(title=\"Execute the plan\", steps=\"existing\", status=\"running\")\n", + "step = chat_feed.append_step(title=\"Execute the plan\", steps=\"append\", status=\"running\")\n", "step.stream(\"\\n\\n...Executing plan...\")" ] }, From 68fb65b3ffc4bec4169b9ae2797bf0506c23c8a5 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Wed, 12 Jun 2024 22:10:45 -0700 Subject: [PATCH 25/30] address comment --- panel/chat/steps.py | 11 +++++------ panel/tests/chat/test_steps.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/panel/chat/steps.py b/panel/chat/steps.py index 60330cc2d3..6b602b740c 100644 --- a/panel/chat/steps.py +++ b/panel/chat/steps.py @@ -11,6 +11,11 @@ class ChatSteps(Column): + objects = param.List( + item_type=ChatStep, + doc="The list of ChatStep objects in the ChatSteps." + ) + step_params = param.Dict( default={}, doc="Parameters to pass to the ChatStep constructor.", @@ -30,12 +35,6 @@ class ChatSteps(Column): _stylesheets = [f"{CDN_DIST}css/chat_steps.css"] - @param.depends("objects", watch=True, on_init=True) - def _validate_steps(self): - for step in self.objects: - if not isinstance(step, ChatStep): - raise ValueError(f"Expected ChatStep, got {step.__class__.__name__}") - def append_step(self, objects: str | list[str] | None = None, **step_params): """ Create a new ChatStep and append it to the ChatSteps. diff --git a/panel/tests/chat/test_steps.py b/panel/tests/chat/test_steps.py index a6051dfe64..6d832f57b1 100644 --- a/panel/tests/chat/test_steps.py +++ b/panel/tests/chat/test_steps.py @@ -23,7 +23,7 @@ def test_append_step_to_inactive(self): def test_validate_steps_with_invalid_step(self): chat_steps = ChatSteps() - with pytest.raises(ValueError): + with pytest.raises(TypeError): chat_steps.append("Not a ChatStep") def test_active_state_management(self): From a5ae22bf86044c53c0f484a3f99b9ae96a6a825d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 26 Jun 2024 10:59:58 +0200 Subject: [PATCH 26/30] Remove ChatSteps --- examples/reference/chat/ChatFeed.ipynb | 23 +--- examples/reference/chat/ChatStep.ipynb | 4 +- examples/reference/chat/ChatSteps.ipynb | 161 ------------------------ panel/chat/__init__.py | 2 - panel/chat/feed.py | 48 ++++--- panel/chat/step.py | 1 + panel/chat/steps.py | 110 ---------------- panel/dist/css/chat_steps.css | 4 - panel/tests/chat/test_feed.py | 7 +- panel/tests/chat/test_steps.py | 39 ------ 10 files changed, 38 insertions(+), 361 deletions(-) delete mode 100644 examples/reference/chat/ChatSteps.ipynb delete mode 100644 panel/chat/steps.py delete mode 100644 panel/dist/css/chat_steps.css delete mode 100644 panel/tests/chat/test_steps.py diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 28ec1d1c8b..df8a7974b8 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -692,7 +692,8 @@ "metadata": {}, "outputs": [], "source": [ - "step = chat_feed.append_step(title=\"Execute the plan\", steps=\"append\", status=\"running\")\n", + "step = chat_feed.append_step(title=\"Execute the plan\", append=True, status=\"running\")\n", + "\n", "step.stream(\"\\n\\n...Executing plan...\")" ] }, @@ -700,25 +701,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Or pass in an existing `ChatSteps` component." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "message = chat_feed.objects[-1]\n", - "steps = message.object\n", - "step = chat_feed.append_step(\"Holding off...\", title=\"Clean up\", status=\"pending\", steps=steps)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "See [`ChatSteps`](ChatSteps.ipynb) and [`ChatStep`](ChatStep.ipynb) for more details on how to use those components." + "See [`ChatStep`](ChatStep.ipynb) for more details on how to use those components." ] }, { diff --git a/examples/reference/chat/ChatStep.ipynb b/examples/reference/chat/ChatStep.ipynb index be2d4fd3c7..bca539f011 100644 --- a/examples/reference/chat/ChatStep.ipynb +++ b/examples/reference/chat/ChatStep.ipynb @@ -340,7 +340,7 @@ "source": [ "chat_step.status = \"running\"\n", "for char in \"It's streaming a title!\":\n", - " time.sleep(0.1)\n", + " time.sleep(0.02)\n", " chat_step.stream_title(char)" ] }, @@ -368,7 +368,7 @@ "outputs": [], "source": [ "for char in \"I'm deciding on a title... how about this one?\":\n", - " time.sleep(0.1)\n", + " time.sleep(0.01)\n", " chat_step.stream_title(char, status=\"pending\")" ] }, diff --git a/examples/reference/chat/ChatSteps.ipynb b/examples/reference/chat/ChatSteps.ipynb deleted file mode 100644 index 98ba36ba05..0000000000 --- a/examples/reference/chat/ChatSteps.ipynb +++ /dev/null @@ -1,161 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "import panel as pn\n", - "\n", - "pn.extension()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The `ChatSteps` component is a specialized `Column`, particularly used for holding and adding [`ChatStep`](ChatStep.ipynb).\n", - "\n", - "Check out the [panel-chat-examples](https://holoviz-topics.github.io/panel-chat-examples/) docs to see applicable examples related to [LangChain](https://python.langchain.com/docs/get_started/introduction), [OpenAI](https://openai.com/blog/chatgpt), [Mistral](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwjZtP35yvSBAxU00wIHHerUDZAQFnoECBEQAQ&url=https%3A%2F%2Fdocs.mistral.ai%2F&usg=AOvVaw2qpx09O_zOzSksgjBKiJY_&opi=89978449), [Llama](https://ai.meta.com/llama/), etc. If you have an example to demo, we'd love to add it to the panel-chat-examples gallery!\n", - "\n", - "#### Parameters:\n", - "\n", - "##### Core\n", - "\n", - "* **`step_params`** (`dict`): Parameters to pass to the ChatStep constructor. Defaults to `{}`.\n", - "* **`active`** (`bool`): Whether additional steps can be appended to the ChatSteps. Defaults to `True`.\n", - "\n", - "___" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Basics" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "`ChatSteps` can be initialized without any arguments." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "chat_steps = pn.chat.ChatSteps()\n", - "chat_steps" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To add a step, you can add it manually." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "chat_steps.objects = [pn.chat.ChatStep(objects=[\"Content\"], title=\"This is a step\")]" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Or more conveniently, through the `append_step` method." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "chat_steps.append_step(\"New Content\", title=\"This is a new step\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To convert the objects into a string, call `serialize`, or simply use `str()`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "chat_steps.serialize()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "`ChatSteps` can also be used as a context manager, which will make it active." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with chat_steps:\n", - " chat_steps.append_step(\"Newest Content\", title=\"This is the newest step\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "When inactive, steps cannot be added to it." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "with chat_steps:\n", - " ...\n", - "\n", - "try:\n", - " chat_steps.append_step()\n", - "except ValueError:\n", - " print(\"Cannot attach a step when the ChatSteps is not active.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The `ChatSteps` is can be invoked through [`ChatFeed`](ChatFeed.ipynb#Steps) too." - ] - } - ], - "metadata": { - "language_info": { - "name": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index 848ce74620..729fbb531a 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -37,7 +37,6 @@ from .interface import ChatInterface # noqa from .message import ChatMessage # noqa from .step import ChatStep # noqa -from .steps import ChatSteps # noqa def __getattr__(name): @@ -56,6 +55,5 @@ def __getattr__(name): "ChatMessage", "ChatReactionIcons", "ChatStep", - "ChatSteps", "langchain", ) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 84f058bad3..7cb357c04a 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -22,16 +22,16 @@ from .._param import Margin from ..io.resources import CDN_DIST -from ..layout import Feed, ListPanel +from ..layout import Column, Feed, ListPanel from ..layout.card import Card from ..layout.spacer import VSpacer from ..pane.image import SVG +from ..pane.markup import Markdown from ..util import to_async_gen from ..viewable import Children from .icon import ChatReactionIcons from .message import ChatMessage from .step import ChatStep -from .steps import ChatSteps if TYPE_CHECKING: from bokeh.document import Document @@ -62,9 +62,10 @@ class StopCallback(Exception): class ChatFeed(ListPanel): """ - A widget to display a list of `ChatMessage` objects and interact with them. + A ChatFeed holds a list of `ChatMessage` objects and provides convenient APIs. + to interact with them. - This widget provides methods to: + This includes methods to: - Send (append) messages to the chat log. - Stream tokens to the latest `ChatMessage` in the chat log. - Execute callbacks when a user sends a message. @@ -695,7 +696,7 @@ def stream( def append_step( self, objects: str | list[str] | None = None, - steps: Literal["new", "append"] | ChatSteps = "new", + append: bool = False, user: str | None = None, avatar: str | bytes | BytesIO | None = None, **step_params @@ -707,11 +708,8 @@ def append_step( --------- objects : str | list(str) | None The objects to stream to the step. - steps : ChatSteps | None - The ChatSteps component to attach the step to. - If "new", creates a new ChatSteps component and streams it to the chat. - If "append", appends the step to the latest active ChatSteps component in the chat. - Else, pass the ChatSteps component directly. + append : bool + Whether to append to existing steps or create new steps. user : str | None The user to stream as; overrides the message's user if provided. Will default to the user parameter. Only applicable if steps is "new". @@ -721,20 +719,32 @@ def append_step( step_params : dict Parameters to pass to the ChatStep. """ - if steps == "new": - steps = ChatSteps() - self.stream(steps, user=user, avatar=avatar) - elif steps == "append": + if objects is not None: + if not isinstance(objects, list): + objects = [objects] + objects = [ + ( + Markdown(obj, css_classes=["step-message"]) + if isinstance(obj, str) + else obj + ) + for obj in objects + ] + step_params["objects"] = objects + step = ChatStep(**step_params) + if append: for message in reversed(self._chat_log.objects): obj = message.object - if isinstance(obj, ChatSteps) and obj.active: + if isinstance(obj, Column) and all(isinstance(o, ChatStep) for o in obj): steps = obj break else: - raise ValueError("No active ChatSteps component found to append the step to.") - elif not isinstance(steps, ChatSteps): - raise ValueError(f"The steps argument must be 'new', 'append', or a ChatSteps type, but got {steps}.") - return steps.append_step(objects, **step_params) + raise ValueError("No active steps found to append the step to.") + steps.append(step) + else: + steps = Column(step, css_classes=["chat-steps"]) + self.stream(steps, user=user, avatar=avatar) + return step def respond(self): """ diff --git a/panel/chat/step.py b/panel/chat/step.py index 7d6eed7836..723eeefcf0 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -114,6 +114,7 @@ def __init__(self, *objects, **params): self._title_pane, stylesheets=self._stylesheets + self.param.stylesheets.rx(), css_classes=["step-header"], + margin=(5, 0) ) def __enter__(self): diff --git a/panel/chat/steps.py b/panel/chat/steps.py deleted file mode 100644 index 6b602b740c..0000000000 --- a/panel/chat/steps.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import param - -from ..io.resources import CDN_DIST -from ..layout import Column -from ..pane.markup import Markdown -from .step import ChatStep -from .utils import serialize_recursively - - -class ChatSteps(Column): - - objects = param.List( - item_type=ChatStep, - doc="The list of ChatStep objects in the ChatSteps." - ) - - step_params = param.Dict( - default={}, - doc="Parameters to pass to the ChatStep constructor.", - ) - - active = param.Boolean( - default=True, - doc="Whether additional steps can be automatically appended to the ChatSteps.", - ) - - css_classes = param.List( - default=["chat-steps"], - doc="CSS classes to apply to the component.", - ) - - _rename = {"step_params": None, "active": None, **Column._rename} - - _stylesheets = [f"{CDN_DIST}css/chat_steps.css"] - - def append_step(self, objects: str | list[str] | None = None, **step_params): - """ - Create a new ChatStep and append it to the ChatSteps. - - Arguments - --------- - objects : str | list[str] | None - The initial object or objects to append to the ChatStep. - **step_params : dict - Parameters to pass to the ChatStep constructor. - - Returns - ------- - ChatStep - The newly created ChatStep. - """ - if not self.active: - raise ValueError( - "Cannot attach a step when the ChatSteps is not active." - ) - - merged_step_params = self.step_params.copy() - if objects is not None: - if not isinstance(objects, list): - objects = [objects] - objects = [ - ( - Markdown(obj, css_classes=["step-message"]) - if isinstance(obj, str) - else obj - ) - for obj in objects - ] - step_params["objects"] = objects - merged_step_params.update(step_params) - step = ChatStep(**merged_step_params) - self.append(step) - return step - - def serialize( - self, - prefix_with_viewable_label: bool = True, - prefix_with_container_label: bool = True, - ) -> str: - """ - Format the objects to a string. - - Arguments - --------- - prefix_with_viewable_label : bool - Whether to include the name of the Viewable, or type - of the viewable if no name is specified. - prefix_with_container_label : bool - Whether to include the name of the container, or type - of the container if no name is specified. - - Returns - ------- - str - The serialized string. - """ - return serialize_recursively( - self, - prefix_with_viewable_label=prefix_with_viewable_label, - prefix_with_container_label=prefix_with_container_label, - ) - - def __enter__(self): - self.active = True - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.active = False diff --git a/panel/dist/css/chat_steps.css b/panel/dist/css/chat_steps.css deleted file mode 100644 index 7da1904bc4..0000000000 --- a/panel/dist/css/chat_steps.css +++ /dev/null @@ -1,4 +0,0 @@ -:host { - padding-block: 0px; - max-width: calc(100% - 30px); -} diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index be828cdc69..63e3fe7b12 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -7,7 +7,6 @@ from panel.chat.icon import ChatReactionIcons from panel.chat.message import DEFAULT_AVATARS, ChatMessage from panel.chat.step import ChatStep -from panel.chat.steps import ChatSteps from panel.chat.utils import avatar_lookup from panel.layout import Column, Row from panel.pane.image import Image @@ -203,13 +202,13 @@ def test_append_step(self, chat_feed): assert isinstance(message, ChatMessage) steps = message.object - assert isinstance(steps, ChatSteps) + assert isinstance(steps, Column) assert len(steps) == 1 assert isinstance(steps[0], ChatStep) # existing - with chat_feed.append_step("New Object", title="New Title", steps="append") as step: + with chat_feed.append_step("New Object", title="New Title", append=True) as step: assert isinstance(step, ChatStep) assert step.title == "New Title" assert step.objects[0].object == "New Object" @@ -218,7 +217,7 @@ def test_append_step(self, chat_feed): assert isinstance(steps[1], ChatStep) # actual component - with chat_feed.append_step("Newest Object", title="Newest Title", steps=steps) as step: + with chat_feed.append_step("Newest Object", title="Newest Title", append=True) as step: assert isinstance(step, ChatStep) assert step.title == "Newest Title" assert step.objects[0].object == "Newest Object" diff --git a/panel/tests/chat/test_steps.py b/panel/tests/chat/test_steps.py deleted file mode 100644 index 6d832f57b1..0000000000 --- a/panel/tests/chat/test_steps.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest - -from panel.chat.steps import ChatStep, ChatSteps -from panel.pane.markup import Markdown - - -class TestChatSteps: - def test_append_step(self): - chat_steps = ChatSteps() - chat_step = chat_steps.append_step("Hello World") - assert isinstance(chat_step, ChatStep) - assert len(chat_steps.objects) == 1 - assert chat_steps.objects[0] == chat_step - assert isinstance(chat_steps.objects[0].objects[0], Markdown) - assert chat_steps.objects[0].objects[0].object == "Hello World" - - def test_append_step_to_inactive(self): - chat_steps = ChatSteps() - with chat_steps: - pass - with pytest.raises(ValueError): - chat_steps.append_step("Hello World") - - def test_validate_steps_with_invalid_step(self): - chat_steps = ChatSteps() - with pytest.raises(TypeError): - chat_steps.append("Not a ChatStep") - - def test_active_state_management(self): - chat_steps = ChatSteps() - with chat_steps: - assert chat_steps.active is True - assert chat_steps.active is False - - def test_serialization(self): - chat_steps = ChatSteps() - chat_steps.append_step("Test Serialization") - serialized_data = chat_steps.serialize() - assert "Test Serialization" in serialized_data From add7b7ad445c9ce5fb00c7664bf7626c06e5a52a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 26 Jun 2024 15:56:20 +0200 Subject: [PATCH 27/30] Adjust append logic --- panel/chat/feed.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 7cb357c04a..7401755daf 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -732,18 +732,24 @@ def append_step( ] step_params["objects"] = objects step = ChatStep(**step_params) + steps_column = None if append: - for message in reversed(self._chat_log.objects): - obj = message.object - if isinstance(obj, Column) and all(isinstance(o, ChatStep) for o in obj): - steps = obj - break - else: - raise ValueError("No active steps found to append the step to.") - steps.append(step) + last = self._chat_log[-1] if self._chat_log else None + if isinstance(last.object, Column) and ( + all(isinstance(o, ChatStep) for o in last.object) or + last.object.css_classes == 'chat-steps' + ) and (user is None or last.user == user): + steps_column = last.object + if steps_column is None: + steps_column = Column( + step, css_classes=["chat-steps"], styles={ + 'max-width': 'calc(100% - 30px)', + 'padding-block': '0px' + } + ) + self.stream(steps_column, user=user, avatar=avatar) else: - steps = Column(step, css_classes=["chat-steps"]) - self.stream(steps, user=user, avatar=avatar) + steps_column.append(step) return step def respond(self): From f1e0f7360786bfb447790448bf3b0e8afc30d521 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 26 Jun 2024 16:19:45 +0200 Subject: [PATCH 28/30] Rename to add_step --- examples/reference/chat/ChatFeed.ipynb | 39 ++++++++++++++++++++++---- panel/chat/feed.py | 28 +++++++++--------- panel/dist/css/chat_step.css | 6 +++- panel/tests/chat/test_feed.py | 8 +++--- 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index df8a7974b8..86894d5740 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -656,7 +656,7 @@ "source": [ "#### Steps\n", "\n", - "Intermediate steps, like chain of thoughts, can be provided through a series of [`ChatStep`](ChatStep.ipynb), which is conveniently accessible through `append_step`." + "Intermediate steps, like chain of thoughts, can be provided through a series of [`ChatStep`s](ChatStep.ipynb)." ] }, { @@ -669,13 +669,20 @@ "chat_feed" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These can easily be added using the `.add_step` method:" + ] + }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "with chat_feed.append_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\") as step:\n", + "with chat_feed.add_step(\"To answer the user's query, I need to first create a plan.\", title=\"Create a plan\", user='Agent') as step:\n", " step.stream(\"\\n\\n...Okay the plan is to demo this!\")" ] }, @@ -683,7 +690,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "To append to an existing `ChatSteps`, you can specify `steps=\"append\"` instead of the default `steps=\"new\"`." + "By default this method will attempt to append the step to an existing message as long as the last message is a step and the user matches." ] }, { @@ -692,9 +699,29 @@ "metadata": {}, "outputs": [], "source": [ - "step = chat_feed.append_step(title=\"Execute the plan\", append=True, status=\"running\")\n", - "\n", - "step.stream(\"\\n\\n...Executing plan...\")" + "with chat_feed.add_step(title=\"Execute the plan\", status=\"running\") as step:\n", + " step.stream(\"\\n\\n...Executing plan...\")\n", + " await asyncio.sleep(1)\n", + " step.stream(\"\\n\\n...Handing over to SQL Agent\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If the user does not match a new message will be created:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with chat_feed.add_step(title=\"Running SQL query\", user='SQL Agent') as step:\n", + " step.stream('Querying...')\n", + " await asyncio.sleep(1)\n", + " step.stream('\\nSELECT * FROM TABLE')" ] }, { diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 7401755daf..1a3a876ee9 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -693,20 +693,21 @@ def stream( self.param.trigger("_post_hook_trigger") return message - def append_step( + def add_step( self, - objects: str | list[str] | None = None, - append: bool = False, + step: str | list[str] | ChatStep | None = None, + append: bool = True, user: str | None = None, avatar: str | bytes | BytesIO | None = None, **step_params ) -> ChatStep: """ - Appends a step to a ChatSteps component. + Adds a ChatStep component either by appending it to an existing + ChatMessage or creating a new ChatMessage. Arguments --------- - objects : str | list(str) | None + step : str | list(str) | ChatStep | None The objects to stream to the step. append : bool Whether to append to existing steps or create new steps. @@ -719,23 +720,24 @@ def append_step( step_params : dict Parameters to pass to the ChatStep. """ - if objects is not None: - if not isinstance(objects, list): - objects = [objects] - objects = [ + if not isinstance(step, ChatStep): + if step is None: + step = [] + elif not isinstance(step, list): + step = [step] + step_params["objects"] = [ ( Markdown(obj, css_classes=["step-message"]) if isinstance(obj, str) else obj ) - for obj in objects + for obj in step ] - step_params["objects"] = objects - step = ChatStep(**step_params) + step = ChatStep(**step_params) steps_column = None if append: last = self._chat_log[-1] if self._chat_log else None - if isinstance(last.object, Column) and ( + if last is not None and isinstance(last.object, Column) and ( all(isinstance(o, ChatStep) for o in last.object) or last.object.css_classes == 'chat-steps' ) and (user is None or last.user == user): diff --git a/panel/dist/css/chat_step.css b/panel/dist/css/chat_step.css index 5b718dec13..334ec1d6a7 100644 --- a/panel/dist/css/chat_step.css +++ b/panel/dist/css/chat_step.css @@ -1,7 +1,11 @@ /* inherit from chat_message */ :host(.card) { - box-shadow: none; + box-shadow: + color-mix(in srgb, var(--panel-shadow-color) 30%, transparent) 0px 1px 2px + 0px, + color-mix(in srgb, var(--panel-shadow-color) 15%, transparent) 0px 1px 3px + 1px; background-color: var(--panel-surface-color, #f1f1f1); } diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 63e3fe7b12..8d9ea6d601 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -190,9 +190,9 @@ def test_stream(self, chat_feed): assert chat_feed.objects[1] is new_entry assert chat_feed.objects[1].object == "New message" - def test_append_step(self, chat_feed): + def test_add_step(self, chat_feed): # new - with chat_feed.append_step("Object", title="Title") as step: + with chat_feed.add_step("Object", title="Title") as step: assert isinstance(step, ChatStep) assert step.title == "Title" assert step.objects[0].object == "Object" @@ -208,7 +208,7 @@ def test_append_step(self, chat_feed): assert isinstance(steps[0], ChatStep) # existing - with chat_feed.append_step("New Object", title="New Title", append=True) as step: + with chat_feed.add_step("New Object", title="New Title", append=True) as step: assert isinstance(step, ChatStep) assert step.title == "New Title" assert step.objects[0].object == "New Object" @@ -217,7 +217,7 @@ def test_append_step(self, chat_feed): assert isinstance(steps[1], ChatStep) # actual component - with chat_feed.append_step("Newest Object", title="Newest Title", append=True) as step: + with chat_feed.add_step("Newest Object", title="Newest Title", append=True) as step: assert isinstance(step, ChatStep) assert step.title == "Newest Title" assert step.objects[0].object == "Newest Object" From 032c095faa21023df5182267ddb7524c86a95fe4 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 26 Jun 2024 16:50:52 +0200 Subject: [PATCH 29/30] Update docstrings --- panel/chat/message.py | 4 +++- panel/chat/step.py | 56 ++++++++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/panel/chat/message.py b/panel/chat/message.py index a2f9ed4843..6a50173443 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -135,9 +135,11 @@ class _FileInputMessage: class ChatMessage(Pane): """ - A widget for displaying chat messages with support for various content types. + Renders another component as a chat message with an associated user + and avatar with support for various content types. This widget provides a structured view of chat messages, including features like: + - Displaying user avatars, which can be text, emoji, or images. - Showing the user's name. - Displaying the message timestamp in a customizable format. diff --git a/panel/chat/step.py b/panel/chat/step.py index 723eeefcf0..2568b643a5 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -24,53 +24,54 @@ class ChatStep(Card): - collapsed = param.Boolean( - default=False, - doc="Whether the contents of the Card are collapsed.") + """ + A component that makes it easy to provide status updates and the + ability to stream updates to both the output(s) and the title. - collapsed_on_success = param.Boolean( - default=True, - doc="Whether to collapse the card on completion.") + Reference: https://panel.holoviz.org/reference/chat/ChatStep.html + + :Example: + + >>> ChatStep("Hello world!", title="Running calculation...', status="running") + """ + + collapsed = param.Boolean(default=False, doc=""" + Whether the contents of the Card are collapsed.""") + + collapsed_on_success = param.Boolean(default=True, doc=""" + Whether to collapse the card on completion.""") success_title = param.String(default=None, doc=""" Title to display when status is success.""") - default_badges = param.Dict( - default=DEFAULT_STATUS_BADGES, doc=""" + default_badges = param.Dict(default=DEFAULT_STATUS_BADGES, doc=""" Mapping from status to default status badge; keys must be one of 'pending', 'running', 'success', 'failed'. """) - default_title = param.String( - default="", - doc="The default title to display if the other title params are unset.") + default_title = param.String(default="", doc=""" + The default title to display if the other title params are unset.""") - failed_title = param.String( - default=None, - doc="Title to display when status is failed.") + failed_title = param.String(default=None, doc=""" + Title to display when status is failed.""") header = param.Parameter(doc=""" A Panel component to display in the header bar of the Card. Will override the given title if defined.""", readonly=True) - margin = param.Parameter( - default=(5, 5, 5, 10), doc=""" + margin = param.Parameter(default=(5, 5, 5, 10), doc=""" Allows to create additional space around the component. May be specified as a two-tuple of the form (vertical, horizontal) or a four-tuple (top, right, bottom, left).""") - pending_title = param.String( - default=None, - doc="Title to display when status is pending." - ) + pending_title = param.String(default=None, doc=""" + Title to display when status is pending.""") - running_title = param.String( - default=None, - doc="Title to display when status is running." - ) + running_title = param.String(default=None, doc=""" + Title to display when status is running.""") - status = param.Selector( - default="pending", objects=["pending", "running", "success", "failed"]) + status = param.Selector(default="pending", objects=[ + "pending", "running", "success", "failed"]) title = param.String(default="", constant=True, doc=""" The title of the chat step. Will redirect to default_title on init. @@ -224,7 +225,8 @@ def stream(self, token: str, replace: bool = False): The updated message pane. """ if ( - len(self.objects) == 0 or not isinstance(self.objects[-1], HTMLBasePane) or isinstance(self.objects[-1], ImageBase)): + len(self.objects) == 0 or not isinstance(self.objects[-1], HTMLBasePane) or isinstance(self.objects[-1], ImageBase) + ): message = Markdown(token, css_classes=["step-message"]) self.append(message) else: From fe3f0c1df94be61eb1a9b1956b25fd139eb4b300 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 26 Jun 2024 17:00:02 +0200 Subject: [PATCH 30/30] Add tests --- panel/tests/chat/test_feed.py | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 8d9ea6d601..c31e1f7b56 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -208,7 +208,7 @@ def test_add_step(self, chat_feed): assert isinstance(steps[0], ChatStep) # existing - with chat_feed.add_step("New Object", title="New Title", append=True) as step: + with chat_feed.add_step("New Object", title="New Title") as step: assert isinstance(step, ChatStep) assert step.title == "New Title" assert step.objects[0].object == "New Object" @@ -217,7 +217,7 @@ def test_add_step(self, chat_feed): assert isinstance(steps[1], ChatStep) # actual component - with chat_feed.add_step("Newest Object", title="Newest Title", append=True) as step: + with chat_feed.add_step("Newest Object", title="Newest Title") as step: assert isinstance(step, ChatStep) assert step.title == "Newest Title" assert step.objects[0].object == "Newest Object" @@ -226,6 +226,70 @@ def test_add_step(self, chat_feed): assert isinstance(steps[1], ChatStep) assert isinstance(steps[2], ChatStep) + def test_add_step_new_user(self, chat_feed): + with chat_feed.add_step("Object", title="Title", user="A") as step: + assert isinstance(step, ChatStep) + assert step.title == "Title" + assert step.objects[0].object == "Object" + + with chat_feed.add_step("Object 2", title="Title 2", user="B") as step: + assert isinstance(step, ChatStep) + assert step.title == "Title 2" + assert step.objects[0].object == "Object 2" + + assert len(chat_feed) == 2 + message1 = chat_feed.objects[0] + assert isinstance(message1, ChatMessage) + assert message1.user == "A" + steps1 = message1.object + assert isinstance(steps1, Column) + assert len(steps1) == 1 + assert isinstance(steps1[0], ChatStep) + assert len(steps1[0].objects) == 1 + assert steps1[0].objects[0].object == "Object" + + message2 = chat_feed.objects[1] + assert isinstance(message2, ChatMessage) + assert message2.user == "B" + steps2 = message2.object + assert isinstance(steps2, Column) + assert len(steps2) == 1 + assert isinstance(steps2[0], ChatStep) + assert len(steps2[0].objects) == 1 + assert steps2[0].objects[0].object == "Object 2" + + def test_add_step_explict_not_append(self, chat_feed): + with chat_feed.add_step("Object", title="Title") as step: + assert isinstance(step, ChatStep) + assert step.title == "Title" + assert step.objects[0].object == "Object" + + with chat_feed.add_step("Object 2", title="Title 2", append=False) as step: + assert isinstance(step, ChatStep) + assert step.title == "Title 2" + assert step.objects[0].object == "Object 2" + + assert len(chat_feed) == 2 + message1 = chat_feed.objects[0] + assert isinstance(message1, ChatMessage) + assert message1.user == "User" + steps1 = message1.object + assert isinstance(steps1, Column) + assert len(steps1) == 1 + assert isinstance(steps1[0], ChatStep) + assert len(steps1[0].objects) == 1 + assert steps1[0].objects[0].object == "Object" + + message2 = chat_feed.objects[1] + assert isinstance(message2, ChatMessage) + assert message2.user == "User" + steps2 = message2.object + assert isinstance(steps2, Column) + assert len(steps2) == 1 + assert isinstance(steps2[0], ChatStep) + assert len(steps2[0].objects) == 1 + assert steps2[0].objects[0].object == "Object 2" + def test_stream_with_user_avatar(self, chat_feed): user = "Bob" avatar = "👨"