From a126e215261b2c7ec6340163c0c10e05c1ab31b2 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 09:54:30 -0700 Subject: [PATCH 01/20] Move chat into its own namespace --- panel/{widgets => chat}/chat.py | 0 panel/tests/{widgets => chat}/test_chat.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename panel/{widgets => chat}/chat.py (100%) rename panel/tests/{widgets => chat}/test_chat.py (100%) diff --git a/panel/widgets/chat.py b/panel/chat/chat.py similarity index 100% rename from panel/widgets/chat.py rename to panel/chat/chat.py diff --git a/panel/tests/widgets/test_chat.py b/panel/tests/chat/test_chat.py similarity index 100% rename from panel/tests/widgets/test_chat.py rename to panel/tests/chat/test_chat.py From 3d2f532f7a8e16a95800d1cbac6b6dd137306618 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 09:55:39 -0700 Subject: [PATCH 02/20] Move docs --- examples/reference/{widgets => chat}/ChatEntry.ipynb | 0 examples/reference/{widgets => chat}/ChatFeed.ipynb | 0 examples/reference/{widgets => chat}/ChatInterface.ipynb | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename examples/reference/{widgets => chat}/ChatEntry.ipynb (100%) rename examples/reference/{widgets => chat}/ChatFeed.ipynb (100%) rename examples/reference/{widgets => chat}/ChatInterface.ipynb (100%) diff --git a/examples/reference/widgets/ChatEntry.ipynb b/examples/reference/chat/ChatEntry.ipynb similarity index 100% rename from examples/reference/widgets/ChatEntry.ipynb rename to examples/reference/chat/ChatEntry.ipynb diff --git a/examples/reference/widgets/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb similarity index 100% rename from examples/reference/widgets/ChatFeed.ipynb rename to examples/reference/chat/ChatFeed.ipynb diff --git a/examples/reference/widgets/ChatInterface.ipynb b/examples/reference/chat/ChatInterface.ipynb similarity index 100% rename from examples/reference/widgets/ChatInterface.ipynb rename to examples/reference/chat/ChatInterface.ipynb From c99feffc37245e810e8591d5d7378397737e7b54 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 09:59:28 -0700 Subject: [PATCH 03/20] Update imports --- examples/reference/chat/ChatEntry.ipynb | 10 +++--- examples/reference/chat/ChatFeed.ipynb | 34 ++++++++++----------- examples/reference/chat/ChatInterface.ipynb | 14 ++++----- panel/widgets/langchain.py | 2 +- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/examples/reference/chat/ChatEntry.ipynb b/examples/reference/chat/ChatEntry.ipynb index 16fb1c8f1e..a16fb9fe8e 100644 --- a/examples/reference/chat/ChatEntry.ipynb +++ b/examples/reference/chat/ChatEntry.ipynb @@ -9,7 +9,7 @@ "import asyncio\n", "\n", "import panel as pn\n", - "from panel.widgets import ChatEntry\n", + "from panel.chat import ChatEntry\n", "\n", "pn.extension()\n" ] @@ -175,7 +175,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_entry = pn.widgets.ChatEntry()\n", + "chat_entry = pn.chat.ChatEntry()\n", "chat_entry" ] }, @@ -266,7 +266,7 @@ "metadata": {}, "outputs": [], "source": [ - "pn.widgets.ChatEntry(timestamp_format=\"%b %d, %Y %I:%M %p\")" + "pn.chat.ChatEntry(timestamp_format=\"%b %d, %Y %I:%M %p\")" ] }, { @@ -328,7 +328,7 @@ "metadata": {}, "outputs": [], "source": [ - "pn.widgets.ChatEntry(value=\"Love this!\", reactions=[\"favorite\"])" + "pn.chat.ChatEntry(value=\"Love this!\", reactions=[\"favorite\"])" ] }, { @@ -344,7 +344,7 @@ "metadata": {}, "outputs": [], "source": [ - "entry = pn.widgets.ChatEntry(\n", + "entry = pn.chat.ChatEntry(\n", " value=\"Looks good!\",\n", " reactions=[\"like\"],\n", " reaction_icons={\"like\": \"thumb-up\", \"dislike\": \"thumb-down\"},\n", diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index f421b489f1..8fd786b3e0 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -82,7 +82,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_feed = pn.widgets.ChatFeed()\n", + "chat_feed = pn.chat.ChatFeed()\n", "chat_feed" ] }, @@ -147,9 +147,9 @@ "metadata": {}, "outputs": [], "source": [ - "pn.widgets.ChatFeed(value=[\n", - " pn.widgets.ChatEntry(value=\"I'm an emoji!\", avatar=\"🤖\"),\n", - " pn.widgets.ChatEntry(value=\"I'm an image!\", avatar=\"https://upload.wikimedia.org/wikipedia/commons/6/63/Yumi_UBports.png\"),\n", + "pn.chat.ChatFeed(value=[\n", + " pn.chat.ChatEntry(value=\"I'm an emoji!\", avatar=\"🤖\"),\n", + " pn.chat.ChatEntry(value=\"I'm an image!\", avatar=\"https://upload.wikimedia.org/wikipedia/commons/6/63/Yumi_UBports.png\"),\n", "])" ] }, @@ -187,7 +187,7 @@ "def echo_message(contents, user, instance):\n", " return f\"Echoing... {contents}\"\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=echo_message)\n", + "chat_feed = pn.chat.ChatFeed(callback=echo_message)\n", "chat_feed" ] }, @@ -213,7 +213,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_feed = pn.widgets.ChatFeed(callback=echo_message, callback_user=\"Echo Bot\")\n", + "chat_feed = pn.chat.ChatFeed(callback=echo_message, callback_user=\"Echo Bot\")\n", "chat_feed" ] }, @@ -242,7 +242,7 @@ "def parrot_message(contents, user, instance):\n", " return {\"value\": f\"No, {contents.lower()}\", \"user\": \"Parrot\", \"avatar\": \"🦜\"}\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=parrot_message, callback_user=\"Echo Bot\")\n", + "chat_feed = pn.chat.ChatFeed(callback=parrot_message, callback_user=\"Echo Bot\")\n", "chat_feed" ] }, @@ -289,7 +289,7 @@ "def bad_callback(contents, user, instance):\n", " return 1 / 0\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=bad_callback, callback_exception=\"summary\")\n", + "chat_feed = pn.chat.ChatFeed(callback=bad_callback, callback_exception=\"summary\")\n", "chat_feed" ] }, @@ -325,7 +325,7 @@ " await asyncio.sleep(2.8)\n", " return {\"value\": f\"No, {contents.lower()}\", \"user\": \"Parrot\", \"avatar\": \"🦜\"}\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=parrot_message, callback_user=\"Echo Bot\")\n", + "chat_feed = pn.chat.ChatFeed(callback=parrot_message, callback_user=\"Echo Bot\")\n", "chat_feed" ] }, @@ -361,7 +361,7 @@ " message += character\n", " yield message\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=stream_message)\n", + "chat_feed = pn.chat.ChatFeed(callback=stream_message)\n", "chat_feed" ] }, @@ -392,7 +392,7 @@ " await asyncio.sleep(0.1)\n", " yield character\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=replace_message)\n", + "chat_feed = pn.chat.ChatFeed(callback=replace_message)\n", "chat_feed" ] }, @@ -428,7 +428,7 @@ " message += chunk[\"choices\"][0][\"delta\"].get(\"content\", \"\")\n", " yield message\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=openai_callback)\n", + "chat_feed = pn.chat.ChatFeed(callback=openai_callback)\n", "chat_feed.send(\"Have you heard of HoloViz Panel?\")\n", "```\n", "\n", @@ -466,7 +466,7 @@ " }\n", "\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=chain_message)\n", + "chat_feed = pn.chat.ChatFeed(callback=chain_message)\n", "chat_feed" ] }, @@ -514,7 +514,7 @@ " instance.respond()\n", "\n", "\n", - "chat_feed = pn.widgets.ChatFeed(callback=openai_self_chat, sizing_mode=\"stretch_width\", height=1000).servable()\n", + "chat_feed = pn.chat.ChatFeed(callback=openai_self_chat, sizing_mode=\"stretch_width\", height=1000).servable()\n", "chat_feed.send(\"What is HoloViz Panel?\")\n", "```\n", "\n", @@ -534,7 +534,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_feed = pn.widgets.ChatFeed()\n", + "chat_feed = pn.chat.ChatFeed()\n", "\n", "# creates a new entry\n", "entry = chat_feed.stream(\"Hello\", user=\"Aspiring User\", avatar=\"🤓\")\n", @@ -564,7 +564,7 @@ "metadata": {}, "outputs": [], "source": [ - "chat_feed = pn.widgets.ChatFeed()\n", + "chat_feed = pn.chat.ChatFeed()\n", "chat_feed" ] }, @@ -596,7 +596,7 @@ "entry_params = dict(\n", " default_avatars={\"System\": \"S\", \"User\": \"👤\"}, reaction_icons={\"like\": \"thumb-up\"}\n", ")\n", - "chat_feed = pn.widgets.ChatFeed(entry_params=entry_params)\n", + "chat_feed = pn.chat.ChatFeed(entry_params=entry_params)\n", "chat_feed.send(user=\"System\", value=\"This is the System speaking.\")\n", "chat_feed.send(user=\"User\", value=\"This is the User speaking.\")\n", "chat_feed" diff --git a/examples/reference/chat/ChatInterface.ipynb b/examples/reference/chat/ChatInterface.ipynb index 6e55273707..d186b6f428 100644 --- a/examples/reference/chat/ChatInterface.ipynb +++ b/examples/reference/chat/ChatInterface.ipynb @@ -7,7 +7,7 @@ "outputs": [], "source": [ "import panel as pn\n", - "from panel.widgets import ChatInterface\n", + "from panel.chat import ChatInterface\n", "\n", "pn.extension()" ] @@ -275,7 +275,7 @@ " instance.active = 0 # Change to TextAreaInput tab\n", " return f\"Got {num}.\"\n", "\n", - "pn.widgets.ChatInterface(\n", + "pn.chat.ChatInterface(\n", " callback=guided_get_num,\n", " widgets=[\n", " pn.widgets.TextAreaInput(placeholder=\"Enter some text to get a count!\"),\n", @@ -311,7 +311,7 @@ " pn.widgets.TextAreaInput(placeholder=\"Enter some text to get a count!\"),\n", " pn.widgets.IntSlider(name=\"Number Input\", end=10)\n", "]\n", - "pn.widgets.ChatInterface(\n", + "pn.chat.ChatInterface(\n", " callback=get_num_guided,\n", " widgets=widgets[0],\n", ")" @@ -334,7 +334,7 @@ " pn.widgets.TextAreaInput(placeholder=\"Enter some text to get a count!\"),\n", " pn.widgets.IntSlider(name=\"Number Input\", end=10)\n", "]\n", - "chat_interface = pn.widgets.ChatInterface(\n", + "chat_interface = pn.chat.ChatInterface(\n", " widgets=widgets,\n", ")\n", "print(chat_interface.active_widget)" @@ -355,7 +355,7 @@ "metadata": {}, "outputs": [], "source": [ - "pn.widgets.ChatInterface(\n", + "pn.chat.ChatInterface(\n", " widgets=pn.widgets.TextAreaInput(),\n", " reset_on_send=False,\n", ")" @@ -376,7 +376,7 @@ "metadata": {}, "outputs": [], "source": [ - "pn.widgets.ChatInterface(callback=get_num, show_rerun=False, show_undo=False)" + "pn.chat.ChatInterface(callback=get_num, show_rerun=False, show_undo=False)" ] }, { @@ -392,7 +392,7 @@ "metadata": {}, "outputs": [], "source": [ - "pn.widgets.ChatInterface(callback=get_num, show_button_name=False, width=400)" + "pn.chat.ChatInterface(callback=get_num, show_button_name=False, width=400)" ] } ], diff --git a/panel/widgets/langchain.py b/panel/widgets/langchain.py index 58219a332f..2588c6cf31 100644 --- a/panel/widgets/langchain.py +++ b/panel/widgets/langchain.py @@ -9,7 +9,7 @@ AgentFinish = None LLMResult = None -from panel.widgets import ChatInterface +from panel.chat import ChatInterface class PanelCallbackHandler(BaseCallbackHandler): From 34121df9866a648d822b2e2eaa8daa0f14e4fb9c Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 10:09:28 -0700 Subject: [PATCH 04/20] Add inits --- panel/__init__.py | 2 ++ panel/chat/__init__.py | 37 +++++++++++++++++++++++++++++++++++++ panel/widgets/__init__.py | 3 --- 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 panel/chat/__init__.py diff --git a/panel/__init__.py b/panel/__init__.py index e1e63e2c35..7505e77229 100644 --- a/panel/__init__.py +++ b/panel/__init__.py @@ -47,6 +47,7 @@ """ from param import rx +from . import chat # noqa from . import layout # noqa from . import links # noqa from . import pane # noqa @@ -74,6 +75,7 @@ "__version__", "Accordion", "Card", + "chat", "Column", "FlexBox", "FloatPanel", diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py new file mode 100644 index 0000000000..b768d140f2 --- /dev/null +++ b/panel/chat/__init__.py @@ -0,0 +1,37 @@ +""" +Panel chat makes creating chat components easy +============================================================== + +Check out the widget gallery +https://panel.holoviz.org/reference/index.html#chat for inspiration. + +How to use Panel widgets in 3 simple steps +------------------------------------------ + +1. Define your function + +>>> async def repeat_contents(contents, user, instance): +>>> yield contents + +2. Define your widgets and callback. + +>>> chat_interface = ChatInterface(callback=repeat_contents) + +3. Layout the chat interface in a template + +>>> template = pn.template.FastListTemplate( +>>> title="Panel Chat", +>>> main=[chat_interface], +>>> ) +>>> template.servable() + +For more detail see the Reference Gallery guide. +https://panel.holoviz.org/reference/chat/ChatInterface.html +""" +from .chat import ChatEntry, ChatFeed, ChatInterface # noqa + +__all__ = ( + "ChatEntry", + "ChatFeed", + "ChatInterfaces", +) diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index ace965db4d..ede16a7c3e 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -34,9 +34,6 @@ """ from .base import CompositeWidget, Widget # noqa from .button import Button, MenuButton, Toggle # noqa -from .chat import ( # noqa - ChatEntry, ChatFeed, ChatInterface, ChatReactionIcons, -) from .chatbox import ChatBox # noqa from .codeeditor import Ace, CodeEditor # noqa from .debugger import Debugger # noqa From 8f8940746f336815f2febd6ea88be962cb1c770a Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 10:20:55 -0700 Subject: [PATCH 05/20] move langchain --- panel/chat/__init__.py | 4 +++- panel/{widgets => chat}/langchain.py | 0 panel/widgets/__init__.py | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) rename panel/{widgets => chat}/langchain.py (100%) diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index b768d140f2..92728a3379 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -29,9 +29,11 @@ https://panel.holoviz.org/reference/chat/ChatInterface.html """ from .chat import ChatEntry, ChatFeed, ChatInterface # noqa +from .langchain import PanelCallbackHandler # noqa __all__ = ( "ChatEntry", "ChatFeed", - "ChatInterfaces", + "ChatInterface", + "PanelCallbackHandler", ) diff --git a/panel/widgets/langchain.py b/panel/chat/langchain.py similarity index 100% rename from panel/widgets/langchain.py rename to panel/chat/langchain.py diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index ede16a7c3e..36fb5ca10a 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -48,7 +48,6 @@ FloatInput, IntInput, LiteralInput, NumberInput, PasswordInput, Spinner, StaticText, Switch, TextAreaInput, TextInput, ) -from .langchain import PanelCallbackHandler # noqa from .misc import FileDownload, JSONEditor, VideoStream # noqa from .player import DiscretePlayer, Player # noqa from .select import ( # noqa From c8c87cca41478fb037bccec30afdcc9084fc3125 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 10:23:27 -0700 Subject: [PATCH 06/20] Update references --- panel/chat/chat.py | 8 ++++---- panel/widgets/chatbox.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/panel/chat/chat.py b/panel/chat/chat.py index 36cdc7307d..a9d03dc2bf 100644 --- a/panel/chat/chat.py +++ b/panel/chat/chat.py @@ -187,7 +187,7 @@ class ChatReactionIcons(ReactiveHTML): The mapping of reactions to their corresponding active icon names; if not set, the active icon name will default to its "filled" version. - Reference: https://panel.holoviz.org/reference/widgets/ChatReactionIcons.html + Reference: https://panel.holoviz.org/reference/chat/ChatReactionIcons.html :Example: @@ -358,7 +358,7 @@ class ChatEntry(CompositeWidget): - Associating reactions with messages and mapping them to icons. - Rendering various content types including text, images, audio, video, and more. - Reference: https://panel.holoviz.org/reference/widgets/ChatEntry.html + Reference: https://panel.holoviz.org/reference/chat/ChatEntry.html :Example: @@ -798,7 +798,7 @@ class ChatFeed(CompositeWidget): - Undo a number of sent `ChatEntry` objects. - Clear the chat log of all `ChatEntry` objects. - Reference: https://panel.holoviz.org/reference/widgets/ChatFeed.html + Reference: https://panel.holoviz.org/reference/chat/ChatFeed.html :Example: @@ -1278,7 +1278,7 @@ class ChatInterface(ChatFeed): """ High level widget that contains the chat log and the chat input. - Reference: https://panel.holoviz.org/reference/widgets/ChatInterface.html + Reference: https://panel.holoviz.org/reference/chat/ChatInterface.html :Example: diff --git a/panel/widgets/chatbox.py b/panel/widgets/chatbox.py index cb3564df96..5151ea8cfb 100644 --- a/panel/widgets/chatbox.py +++ b/panel/widgets/chatbox.py @@ -207,7 +207,7 @@ class ChatBox(CompositeWidget): The ChatBox widget displays a conversation between multiple users composed of users' icons, names, messages, and likes. - Reference: https://panel.holoviz.org/reference/widgets/ChatBox.html + Reference: https://panel.holoviz.org/reference/chat/ChatBox.html :Example: From 681f9d86dd623199a84f112630df9ab65208a944 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 10:46:31 -0700 Subject: [PATCH 07/20] Rename chat.chat to chat.base and extract out icons --- examples/reference/chat/ChatFeed.ipynb | 2 +- panel/chat/__init__.py | 5 +- panel/chat/{chat.py => base.py} | 621 +++++++++++-------------- panel/chat/icon.py | 210 +++++++++ panel/tests/chat/test_chat.py | 7 +- panel/tests/widgets/test_chatbox.py | 2 +- panel/widgets/chatbox.py | 2 +- 7 files changed, 487 insertions(+), 362 deletions(-) rename panel/chat/{chat.py => base.py} (74%) create mode 100644 panel/chat/icon.py diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 8fd786b3e0..7334706f59 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -619,7 +619,7 @@ "source": [ "import asyncio\n", "import panel as pn\n", - "from panel.widgets.chat import ChatEntry, ChatFeed\n", + "from panel.chat import ChatEntry, ChatFeed\n", "\n", "pn.extension()\n", "\n", diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index 92728a3379..4b3f4fbd0c 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -28,12 +28,15 @@ For more detail see the Reference Gallery guide. https://panel.holoviz.org/reference/chat/ChatInterface.html """ -from .chat import ChatEntry, ChatFeed, ChatInterface # noqa +from .base import ( # noqa + ChatEntry, ChatFeed, ChatInterface, ChatReactionIcons, +) from .langchain import PanelCallbackHandler # noqa __all__ = ( "ChatEntry", "ChatFeed", "ChatInterface", + "ChatReactionIcons", "PanelCallbackHandler", ) diff --git a/panel/chat/chat.py b/panel/chat/base.py similarity index 74% rename from panel/chat/chat.py rename to panel/chat/base.py index a9d03dc2bf..1fe90c53ac 100644 --- a/panel/chat/chat.py +++ b/panel/chat/base.py @@ -22,12 +22,9 @@ ) import param -import requests from .._param import Margin -from ..io.cache import cache from ..io.resources import CDN_DIST -from ..io.state import state from ..layout import ( Column, ListPanel, Row, Tabs, ) @@ -39,11 +36,11 @@ ) from ..pane.markup import HTML, DataFrame, HTMLBasePane from ..pane.media import Audio, Video -from ..reactive import ReactiveHTML from ..viewable import Viewable -from .base import CompositeWidget, Widget -from .button import Button -from .input import FileInput, TextInput +from ..widgets.base import CompositeWidget, Widget +from ..widgets.button import Button +from ..widgets.input import FileInput, TextInput +from .icon import ChatCopyIcon, ChatReactionIcons Avatar = Union[str, BytesIO, ImageBase] AvatarDict = Dict[str, Avatar] @@ -109,23 +106,6 @@ """ # noqa: E501 -# if user cannot connect to internet -MISSING_SVG = """ - - - - - - -""" # noqa: E501 - -MISSING_FILLED_SVG = """ - - - - -""" # noqa: E501 - @dataclass class _FileInputMessage: @@ -141,6 +121,7 @@ class _FileInputMessage: mime_type : str The mime type of the file. """ + contents: bytes file_name: str mime_type: str @@ -165,6 +146,7 @@ class _ChatButtonData: buttons : List The buttons to display. """ + index: int name: str icon: str @@ -172,181 +154,6 @@ class _ChatButtonData: buttons: List -class ChatReactionIcons(ReactiveHTML): - """ - A widget to display reaction icons that can be clicked on. - - Parameters - ---------- - value : List - The selected reactions. - options : Dict - A key-value pair of reaction values and their corresponding tabler icon names - found on https://tabler-icons.io. - active_icons : Dict - The mapping of reactions to their corresponding active icon names; - if not set, the active icon name will default to its "filled" version. - - Reference: https://panel.holoviz.org/reference/chat/ChatReactionIcons.html - - :Example: - - >>> ChatReactionIcons(value=["like"], options={"like": "thumb-up", "dislike": "thumb-down"}) - """ - - active_icons = param.Dict(default={}, doc=""" - The mapping of reactions to their corresponding active icon names; - if not set, the active icon name will default to its "filled" version.""") - - options = param.Dict(default={"favorite": "heart"}, doc=""" - A key-value pair of reaction values and their corresponding tabler icon names - found on https://tabler-icons.io.""") - - value = param.List(doc="The active reactions.") - - _reactions = param.List(doc=""" - The list of reactions, which is the same as the keys of the options dict; - primarily needed as a workaround for quirks of ReactiveHTML.""") - - _svgs = param.List(doc=""" - The list of SVGs corresponding to the active reactions.""") - - _base_url = param.String(default="https://tabler-icons.io/static/tabler-icons/icons/", doc=""" - The base URL for the SVGs.""") - - _template = """ -
- {% for option in options %} - - ${_svgs[{{ loop.index0 }}]} - - {% endfor %} -
- """ - - _scripts = { - "toggle_value": """ - svg = event.target.shadowRoot.querySelector("svg"); - const reaction = svg.getAttribute("alt"); - const icon_name = data.options[reaction]; - let src; - if (data.value.includes(reaction)) { - src = `${data._base_url}${icon_name}.svg`; - data.value = data.value.filter(r => r !== reaction); - } else { - src = reaction in data.active_icons - ? `${data._base_url}${data.active_icons[reaction]}.svg` - : `${data._base_url}${icon_name}-filled.svg`; - data.value = [...data.value, reaction]; - } - event.target.src = src; - """ - } - - _stylesheets: ClassVar[List[str]] = [ - f"{CDN_DIST}css/chat_reaction_icons.css" - ] - - def _get_label(self, active: bool, reaction: str, icon: str): - if active and reaction in self.active_icons: - icon_label = self.active_icons[reaction] - elif active: - icon_label = f"{icon}-filled" - else: - icon_label = icon - return icon_label - - @cache - def _fetch_svg(self, icon_label: str): - src = f"{self._base_url}{icon_label}.svg" - with requests.get(src) as response: - response.raise_for_status() - svg = response.text - return svg - - def _stylize_svg(self, svg, reaction): - if b"dark" in state.session_args.get("theme", []): - svg = svg.replace('stroke="currentColor"', 'stroke="white"') - svg = svg.replace('fill="currentColor"', 'fill="white"') - if self.width: - svg = svg.replace('width="24"', f'width="{self.width}px"') - if self.height: - svg = svg.replace('height="24"', f'height="{self.height}px"') - svg = svg.replace(" - - - - - - - """ - - _scripts = {"copy_to_clipboard": "navigator.clipboard.writeText(`${data.value}`)"} - - _stylesheets: ClassVar[List[str]] = [ - f"{CDN_DIST}css/chat_copy_icon.css" - ] - - class ChatEntry(CompositeWidget): """ A widget for displaying chat messages with support for various content types. @@ -365,64 +172,100 @@ class ChatEntry(CompositeWidget): >>> ChatEntry(value="Hello world!", user="New User", avatar="😊") """ - avatar = param.ClassSelector(default="", class_=(str, BinaryIO, ImageBase), doc=""" + avatar = param.ClassSelector( + default="", + class_=(str, BinaryIO, ImageBase), + doc=""" The avatar to use for the user. Can be a single character text, an emoji, or anything supported by `pn.pane.Image`. If not set, checks if the user is available in the default_avatars mapping; else uses the - first character of the name.""") + first character of the name.""", + ) - avatar_lookup = param.Callable(default=None, doc=""" + avatar_lookup = param.Callable( + default=None, + doc=""" A function that can lookup an `avatar` from a user name. The function signature should be - `(user: str) -> Avatar`. If this is set, `default_avatars` is disregarded.""") + `(user: str) -> Avatar`. If this is set, `default_avatars` is disregarded.""", + ) - css_classes = param.List(default=["chat-entry"], doc=""" - The CSS classes to apply to the widget.""") + css_classes = param.List( + default=["chat-entry"], + doc=""" + The CSS classes to apply to the widget.""", + ) - default_avatars = param.Dict(default=DEFAULT_AVATARS, doc=""" + default_avatars = param.Dict( + default=DEFAULT_AVATARS, + doc=""" A default mapping of user names to their corresponding avatars to use when the user is specified but the avatar is. You can modify, but not replace the - dictionary.""") + dictionary.""", + ) - reactions = param.List(doc=""" - Reactions to associate with the message.""") + reactions = param.List( + doc=""" + Reactions to associate with the message.""" + ) - reaction_icons = param.ClassSelector(class_=(ChatReactionIcons, dict), doc=""" + reaction_icons = param.ClassSelector( + class_=(ChatReactionIcons, dict), + doc=""" A mapping of reactions to their reaction icons; if not provided - defaults to `{"favorite": "heart"}`.""") + defaults to `{"favorite": "heart"}`.""", + ) - timestamp = param.Date(doc=""" - Timestamp of the message. Defaults to the creation time.""") + timestamp = param.Date( + doc=""" + Timestamp of the message. Defaults to the creation time.""" + ) timestamp_format = param.String(default="%H:%M", doc="The timestamp format.") - show_avatar = param.Boolean(default=True, doc="Whether to display the avatar of the user.") + show_avatar = param.Boolean( + default=True, doc="Whether to display the avatar of the user." + ) - show_user = param.Boolean(default=True, doc="Whether to display the name of the user.") + show_user = param.Boolean( + default=True, doc="Whether to display the name of the user." + ) - show_timestamp = param.Boolean(default=True, doc="Whether to display the timestamp of the message.") + show_timestamp = param.Boolean( + default=True, doc="Whether to display the timestamp of the message." + ) - show_reaction_icons = param.Boolean(default=True, doc="Whether to display the reaction icons.") + show_reaction_icons = param.Boolean( + default=True, doc="Whether to display the reaction icons." + ) - show_copy_icon = param.Boolean(default=True, doc="Whether to display the copy icon.") + show_copy_icon = param.Boolean( + default=True, doc="Whether to display the copy icon." + ) - renderers = param.HookList(doc=""" + renderers = param.HookList( + doc=""" A callable or list of callables that accept the value and return a Panel object to render the value. If a list is provided, will attempt to use the first renderer that does not raise an exception. If None, will attempt to infer the renderer - from the value.""") + from the value.""" + ) - user = param.Parameter(default="User", doc=""" - Name of the user who sent the message.""") + user = param.Parameter( + default="User", + doc=""" + Name of the user who sent the message.""", + ) - value = param.Parameter(doc=""" - The message contents. Can be any Python object that panel can display.""", allow_refs=False) + value = param.Parameter( + doc=""" + The message contents. Can be any Python object that panel can display.""", + allow_refs=False, + ) _value_panel = param.Parameter(doc="The rendered value panel.") - _stylesheets: ClassVar[List[str]] = [ - f"{CDN_DIST}css/chat_entry.css" - ] + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_entry.css"] def __init__(self, **params): from ..param import ParamMethod # circular imports @@ -430,24 +273,26 @@ def __init__(self, **params): self._exit_stack = ExitStack() self.chat_copy_icon = ChatCopyIcon( - visible=False, width=15, height=15, css_classes=["copy-icon"]) + visible=False, width=15, height=15, css_classes=["copy-icon"] + ) if params.get("timestamp") is None: params["timestamp"] = datetime.datetime.utcnow() if params.get("reaction_icons") is None: params["reaction_icons"] = {"favorite": "heart"} if isinstance(params["reaction_icons"], dict): params["reaction_icons"] = ChatReactionIcons( - options=params["reaction_icons"], width=15, height=15) + options=params["reaction_icons"], width=15, height=15 + ) super().__init__(**params) self.reaction_icons.link(self, value="reactions", bidirectional=True) - self.reaction_icons.link(self, visible="show_reaction_icons", bidirectional=True) + self.reaction_icons.link( + self, visible="show_reaction_icons", bidirectional=True + ) self.param.trigger("reactions", "show_reaction_icons") if not self.avatar: self.param.trigger("avatar_lookup") - render_kwargs = { - "inplace": True, "stylesheets": self._stylesheets - } + render_kwargs = {"inplace": True, "stylesheets": self._stylesheets} left_col = Column( ParamMethod(self._render_avatar, **render_kwargs), max_width=60, @@ -478,8 +323,7 @@ def __init__(self, **params): sizing_mode=None, ) self._composite.param.update( - stylesheets = self._stylesheets, - css_classes = self.css_classes + stylesheets=self._stylesheets, css_classes=self.css_classes ) self._composite[:] = [left_col, right_col] @@ -495,15 +339,14 @@ def _avatar_lookup(self, user: str) -> Avatar: """ Lookup the avatar for the user. """ - alpha_numeric_key = self._to_alpha_numeric(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() + 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) @@ -518,9 +361,7 @@ def _select_renderer( """ renderer = _panel if mime_type == "application/pdf": - contents = self._exit_stack.enter_context( - BytesIO(contents) - ) + contents = self._exit_stack.enter_context(BytesIO(contents)) renderer = partial(PDF, embed=True) elif mime_type.startswith("audio/"): file = self._exit_stack.enter_context( @@ -531,17 +372,14 @@ def _select_renderer( contents = file.name renderer = Audio elif mime_type.startswith("video/"): - contents = self._exit_stack.enter_context( - BytesIO(contents) - ) + contents = self._exit_stack.enter_context(BytesIO(contents)) renderer = Video elif mime_type.startswith("image/"): - contents = self._exit_stack.enter_context( - BytesIO(contents) - ) + contents = self._exit_stack.enter_context(BytesIO(contents)) renderer = Image elif mime_type.endswith("/csv"): import pandas as pd + with BytesIO(contents) as buf: contents = pd.read_csv(buf) renderer = DataFrame @@ -560,10 +398,7 @@ def _set_default_attrs(self, obj): self._set_default_attrs(subobj) return None - is_markup = ( - isinstance(obj, HTMLBasePane) and - not isinstance(obj, FileBase) - ) + is_markup = isinstance(obj, HTMLBasePane) and not isinstance(obj, FileBase) if is_markup: if len(str(obj.object)) > 0: # only show a background if there is content obj.css_classes = [*obj.css_classes, "message"] @@ -591,16 +426,13 @@ def _create_panel(self, value): if isinstance(value, _FileInputMessage): contents = value.contents mime_type = value.mime_type - value, renderer = self._select_renderer( - contents, mime_type - ) + value, renderer = self._select_renderer(contents, mime_type) else: try: import magic + mime_type = magic.from_buffer(value, mime=True) - value, renderer = self._select_renderer( - value, mime_type - ) + value, renderer = self._select_renderer(value, mime_type) except Exception: pass @@ -754,7 +586,7 @@ def update( self, value: dict | ChatEntry | Any, user: str | None = None, - avatar: str | BinaryIO | None = None + avatar: str | BinaryIO | None = None, ): """ Updates the entry with a new value, user and avatar. @@ -772,9 +604,9 @@ def update( if isinstance(value, dict): updates.update(value) if user: - updates['user'] = user + updates["user"] = user if avatar: - updates['avatar'] = avatar + updates["avatar"] = avatar elif isinstance(value, ChatEntry): if user is not None or avatar is not None: raise ValueError( @@ -810,11 +642,14 @@ class ChatFeed(CompositeWidget): >>> chat_feed.send("Hello World!", user="New User", avatar="😊") """ - callback = param.Callable(allow_refs=False, doc=""" + callback = param.Callable( + allow_refs=False, + doc=""" Callback to execute when a user sends a message or when `respond` is called. The signature must include the previous message value `contents`, the previous `user` name, - and the component `instance`.""") + and the component `instance`.""", + ) callback_exception = param.ObjectSelector( default="summary", @@ -825,69 +660,112 @@ class ChatFeed(CompositeWidget): If "summary", a summary will be sent to the chat feed. If "verbose", the full traceback will be sent to the chat feed. If "ignore", the exception will be ignored. - """) + """, + ) - callback_user = param.String(default="Assistant", doc=""" - The default user name to use for the entry provided by the callback.""") + callback_user = param.String( + default="Assistant", + doc=""" + The default user name to use for the entry provided by the callback.""", + ) - card_params = param.Dict(default={}, doc=""" + card_params = param.Dict( + default={}, + doc=""" Params to pass to Card, like `header`, - `header_background`, `header_color`, etc.""") + `header_background`, `header_color`, etc.""", + ) - entry_params = param.Dict(default={}, doc=""" + entry_params = param.Dict( + default={}, + doc=""" Params to pass to each ChatEntry, like `reaction_icons`, `timestamp_format`, - `show_avatar`, `show_user`, and `show_timestamp`.""") + `show_avatar`, `show_user`, and `show_timestamp`.""", + ) - header = param.Parameter(doc=""" + header = param.Parameter( + doc=""" The header of the chat feed; commonly used for the title. - Can be a string, pane, or widget.""") + Can be a string, pane, or widget.""" + ) - margin = Margin(default=5, doc=""" + margin = Margin( + default=5, + 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).""", + ) - renderers = param.HookList(doc=""" + renderers = param.HookList( + doc=""" A callable or list of callables that accept the value and return a Panel object to render the value. If a list is provided, will attempt to use the first renderer that does not raise an exception. If None, will attempt to infer the renderer - from the value.""") + from the value.""" + ) - placeholder_text = param.String(default="", doc=""" + placeholder_text = param.String( + default="", + doc=""" If placeholder is the default LoadingSpinner, - the text to display next to it.""") + the text to display next to it.""", + ) - placeholder_threshold = param.Number(default=1, bounds=(0, None), doc=""" + placeholder_threshold = param.Number( + default=1, + bounds=(0, None), + doc=""" Min duration in seconds of buffering before displaying the placeholder. - If 0, the placeholder will be disabled.""") + If 0, the placeholder will be disabled.""", + ) - auto_scroll_limit = param.Integer(default=200, bounds=(0, None), doc=""" + auto_scroll_limit = param.Integer( + default=200, + bounds=(0, None), + doc=""" Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 - disables auto-scrolling.""") + disables auto-scrolling.""", + ) - scroll_button_threshold = param.Integer(default=100, bounds=(0, None), doc=""" + scroll_button_threshold = param.Integer( + default=100, + bounds=(0, None), + doc=""" Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 - disables the scroll button.""") + disables the scroll button.""", + ) - view_latest = param.Boolean(default=True, doc=""" + view_latest = param.Boolean( + default=True, + doc=""" Whether to scroll to the latest object on init. If not - enabled the view will be on the first object.""") - value = param.List(item_type=ChatEntry, doc=""" - The list of entries in the feed.""") + enabled the view will be on the first object.""", + ) + value = param.List( + item_type=ChatEntry, + doc=""" + The list of entries in the feed.""", + ) - _placeholder = param.ClassSelector(class_=ChatEntry, allow_refs=False, doc=""" + _placeholder = param.ClassSelector( + class_=ChatEntry, + allow_refs=False, + doc=""" The placeholder wrapped in a ChatEntry object; - primarily to prevent recursion error in _update_placeholder.""") + primarily to prevent recursion error in _update_placeholder.""", + ) - _disabled = param.Boolean(default=False, doc=""" - Whether the chat feed is disabled.""") + _disabled = param.Boolean( + default=False, + doc=""" + Whether the chat feed is disabled.""", + ) - _stylesheets: ClassVar[List[str]] = [ - f"{CDN_DIST}css/chat_feed.css" - ] + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_feed.css"] _composite_type: ClassVar[Type[ListPanel]] = Card @@ -909,8 +787,11 @@ def __init__(self, **params): "width": self.width, "max_width": self.max_width, "max_height": self.max_height, - "styles": {"border": "1px solid var(--panel-border-color, #e1e1e1)", "padding": "0px"}, - "stylesheets": self._stylesheets + "styles": { + "border": "1px solid var(--panel-border-color, #e1e1e1)", + "padding": "0px", + }, + "stylesheets": self._stylesheets, } card_params.update(**self.card_params) if self.sizing_mode is None: @@ -921,11 +802,7 @@ def __init__(self, **params): chat_log_params = { p: getattr(self, p) for p in Column.param - if ( - p in ChatFeed.param and - p != "name" and - getattr(self, p) is not None - ) + if (p in ChatFeed.param and p != "name" and getattr(self, p) is not None) } chat_log_params["css_classes"] = ["chat-feed-log"] chat_log_params["stylesheets"] = self._stylesheets @@ -943,9 +820,7 @@ def __init__(self, **params): @param.depends("placeholder_text", watch=True, on_init=True) def _update_placeholder(self): loading_avatar = SVG( - PLACEHOLDER_SVG, - sizing_mode=None, - css_classes=["rotating-placeholder"] + PLACEHOLDER_SVG, sizing_mode=None, css_classes=["rotating-placeholder"] ) self._placeholder = ChatEntry( user=" ", @@ -1000,15 +875,17 @@ def _build_entry( ) entry_params = dict(value, renderers=self.renderers, **self.entry_params) if user: - entry_params['user'] = user + entry_params["user"] = user if avatar: - entry_params['avatar'] = avatar + entry_params["avatar"] = avatar if self.width: - entry_params['width'] = int(self.width - 80) + entry_params["width"] = int(self.width - 80) entry = ChatEntry(**entry_params) return entry - def _upsert_entry(self, value: Any, entry: ChatEntry | None = None) -> ChatEntry | None: + def _upsert_entry( + self, value: Any, entry: ChatEntry | None = None + ) -> ChatEntry | None: """ Replace the placeholder entry with the response or update the entry's value with the response. @@ -1020,8 +897,8 @@ def _upsert_entry(self, value: Any, entry: ChatEntry | None = None) -> ChatEntry user = self.callback_user avatar = None if isinstance(value, dict): - user = value.get('user', user) - avatar = value.get('avatar') + user = value.get("user", user) + avatar = value.get("avatar") if entry is not None: entry.update(value, user=user, avatar=avatar) return entry @@ -1085,10 +962,9 @@ async def _schedule_placeholder( if self.placeholder_threshold == 0: return - callable_is_async = ( - asyncio.iscoroutinefunction(self.callback) or - isasyncgenfunction(self.callback) - ) + callable_is_async = asyncio.iscoroutinefunction( + self.callback + ) or isasyncgenfunction(self.callback) start = asyncio.get_event_loop().time() while not task.done() and num_entries == len(self._chat_log): duration = asyncio.get_event_loop().time() - start @@ -1118,10 +994,7 @@ async def _prepare_response(self, _) -> None: await task task.result() except Exception as e: - send_kwargs = dict( - user="Exception", - respond=False - ) + send_kwargs = dict(user="Exception", respond=False) if self.callback_exception == "summary": self.send(str(e), **send_kwargs) elif self.callback_exception == "verbose": @@ -1290,58 +1163,96 @@ class ChatInterface(ChatFeed): ) """ - auto_send_types = param.List(doc=""" + auto_send_types = param.List( + doc=""" The widget types to automatically send when the user presses enter or clicks away from the widget. If not provided, defaults to - `[TextInput]`.""") + `[TextInput]`.""" + ) - avatar = param.ClassSelector(class_=(str, BinaryIO), doc=""" + avatar = param.ClassSelector( + class_=(str, BinaryIO), + doc=""" The avatar to use for the user. Can be a single character text, an emoji, or anything supported by `pn.pane.Image`. If not set, uses the - first character of the name.""") + first character of the name.""", + ) - reset_on_send = param.Boolean(default=False, doc=""" + reset_on_send = param.Boolean( + default=False, + doc=""" Whether to reset the widget's value after sending a message; - has no effect for `TextInput`.""") + has no effect for `TextInput`.""", + ) - show_send = param.Boolean(default=True, doc=""" - Whether to show the send button.""") + show_send = param.Boolean( + default=True, + doc=""" + Whether to show the send button.""", + ) - show_rerun = param.Boolean(default=True, doc=""" - Whether to show the rerun button.""") + show_rerun = param.Boolean( + default=True, + doc=""" + Whether to show the rerun button.""", + ) - show_undo = param.Boolean(default=True, doc=""" - Whether to show the undo button.""") + show_undo = param.Boolean( + default=True, + doc=""" + Whether to show the undo button.""", + ) - show_clear = param.Boolean(default=True, doc=""" - Whether to show the clear button.""") + show_clear = param.Boolean( + default=True, + doc=""" + Whether to show the clear button.""", + ) - show_button_name = param.Boolean(default=None, doc=""" - Whether to show the button name.""") + show_button_name = param.Boolean( + default=None, + doc=""" + Whether to show the button name.""", + ) user = param.String(default="User", doc="Name of the ChatInterface user.") - widgets = param.ClassSelector(class_=(Widget, list), allow_refs=False, doc=""" + widgets = param.ClassSelector( + class_=(Widget, list), + allow_refs=False, + doc=""" Widgets to use for the input. If not provided, defaults to - `[TextInput]`.""") + `[TextInput]`.""", + ) - _widgets = param.Dict(default={}, allow_refs=False, doc=""" - The input widgets.""") + _widgets = param.Dict( + default={}, + allow_refs=False, + doc=""" + The input widgets.""", + ) - _input_container = param.ClassSelector(class_=Row, doc=""" + _input_container = param.ClassSelector( + class_=Row, + doc=""" The input message row that wraps the input layout (Tabs / Row) to easily swap between Tabs and Rows, depending on - number of widgets.""") + number of widgets.""", + ) - _input_layout = param.ClassSelector(class_=(Row, Tabs), doc=""" - The input layout that contains the input widgets.""") + _input_layout = param.ClassSelector( + class_=(Row, Tabs), + doc=""" + The input layout that contains the input widgets.""", + ) - _button_data = param.Dict(default={}, doc=""" - Metadata and data related to the buttons.""") + _button_data = param.Dict( + default={}, + doc=""" + Metadata and data related to the buttons.""", + ) - _stylesheets: ClassVar[List[str]] = [ - f"{CDN_DIST}css/chat_interface.css" - ] + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_interface.css"] def __init__(self, **params): widgets = params.get("widgets") @@ -1364,20 +1275,21 @@ def __init__(self, **params): self._button_data = { name: _ChatButtonData( index=index, name=name, icon=icon, objects=[], buttons=[] - ) for index, (name, icon) in enumerate(button_icons.items()) + ) + for index, (name, icon) in enumerate(button_icons.items()) } self._input_container = Row( css_classes=["chat-interface-input-container"], - stylesheets=self._stylesheets + stylesheets=self._stylesheets, ) self._update_input_width() self._init_widgets() if active is not None: self.active = active self._composite.param.update( - objects=self._composite.objects+[self._input_container], + objects=self._composite.objects + [self._input_container], css_classes=["chat-interface"], - stylesheets=self._stylesheets + stylesheets=self._stylesheets, ) def _link_disabled_loading(self, obj: Viewable): @@ -1445,8 +1357,7 @@ def _init_widgets(self): if isinstance(widget, auto_send_types): widget.param.watch(self._click_send, "value") widget.param.update( - sizing_mode="stretch_width", - css_classes=["chat-interface-input-widget"] + sizing_mode="stretch_width", css_classes=["chat-interface-input-widget"] ) buttons = [] @@ -1541,13 +1452,13 @@ def _toggle_revert(self, button_data: _ChatButtonData, active: bool): button_update = { "button_type": "warning", "name": "Revert", - "width": 90 + "width": 90, } else: button_update = { "button_type": "default", "name": button_data.name.title() if self.show_button_name else "", - "width": 90 if self.show_button_name else 45 + "width": 90 if self.show_button_name else 45, } button.param.update(button_update) diff --git a/panel/chat/icon.py b/panel/chat/icon.py new file mode 100644 index 0000000000..b5194893fe --- /dev/null +++ b/panel/chat/icon.py @@ -0,0 +1,210 @@ +from typing import ClassVar, List + +import param +import requests + +from ..io.cache import cache +from ..io.resources import CDN_DIST +from ..io.state import state +from ..pane.image import SVG +from ..reactive import ReactiveHTML + +# if user cannot connect to internet +MISSING_SVG = """ + + + + + + +""" # noqa: E501 + +MISSING_FILLED_SVG = """ + + + + +""" # noqa: E501 + + +class ChatReactionIcons(ReactiveHTML): + """ + A widget to display reaction icons that can be clicked on. + + Parameters + ---------- + value : List + The selected reactions. + options : Dict + A key-value pair of reaction values and their corresponding tabler icon names + found on https://tabler-icons.io. + active_icons : Dict + The mapping of reactions to their corresponding active icon names; + if not set, the active icon name will default to its "filled" version. + + Reference: https://panel.holoviz.org/reference/chat/ChatReactionIcons.html + + :Example: + + >>> ChatReactionIcons(value=["like"], options={"like": "thumb-up", "dislike": "thumb-down"}) + """ + + active_icons = param.Dict( + default={}, + doc=""" + The mapping of reactions to their corresponding active icon names; + if not set, the active icon name will default to its "filled" version.""", + ) + + options = param.Dict( + default={"favorite": "heart"}, + doc=""" + A key-value pair of reaction values and their corresponding tabler icon names + found on https://tabler-icons.io.""", + ) + + value = param.List(doc="The active reactions.") + + _reactions = param.List( + doc=""" + The list of reactions, which is the same as the keys of the options dict; + primarily needed as a workaround for quirks of ReactiveHTML.""" + ) + + _svgs = param.List( + doc=""" + The list of SVGs corresponding to the active reactions.""" + ) + + _base_url = param.String( + default="https://tabler-icons.io/static/tabler-icons/icons/", + doc=""" + The base URL for the SVGs.""", + ) + + _template = """ +
+ {% for option in options %} + + ${_svgs[{{ loop.index0 }}]} + + {% endfor %} +
+ """ + + _scripts = { + "toggle_value": """ + svg = event.target.shadowRoot.querySelector("svg"); + const reaction = svg.getAttribute("alt"); + const icon_name = data.options[reaction]; + let src; + if (data.value.includes(reaction)) { + src = `${data._base_url}${icon_name}.svg`; + data.value = data.value.filter(r => r !== reaction); + } else { + src = reaction in data.active_icons + ? `${data._base_url}${data.active_icons[reaction]}.svg` + : `${data._base_url}${icon_name}-filled.svg`; + data.value = [...data.value, reaction]; + } + event.target.src = src; + """ + } + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_reaction_icons.css"] + + def _get_label(self, active: bool, reaction: str, icon: str): + if active and reaction in self.active_icons: + icon_label = self.active_icons[reaction] + elif active: + icon_label = f"{icon}-filled" + else: + icon_label = icon + return icon_label + + @cache + def _fetch_svg(self, icon_label: str): + src = f"{self._base_url}{icon_label}.svg" + with requests.get(src) as response: + response.raise_for_status() + svg = response.text + return svg + + def _stylize_svg(self, svg, reaction): + if b"dark" in state.session_args.get("theme", []): + svg = svg.replace('stroke="currentColor"', 'stroke="white"') + svg = svg.replace('fill="currentColor"', 'fill="white"') + if self.width: + svg = svg.replace('width="24"', f'width="{self.width}px"') + if self.height: + svg = svg.replace('height="24"', f'height="{self.height}px"') + svg = svg.replace(" + + + + + + + """ + + _scripts = {"copy_to_clipboard": "navigator.clipboard.writeText(`${data.value}`)"} + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_copy_icon.css"] diff --git a/panel/tests/chat/test_chat.py b/panel/tests/chat/test_chat.py index 1ba33ca5a8..9b8b7e83dc 100644 --- a/panel/tests/chat/test_chat.py +++ b/panel/tests/chat/test_chat.py @@ -7,14 +7,15 @@ import pytest from panel import Param, bind +from panel.chat.base import ( + ChatEntry, ChatFeed, ChatInterface, _FileInputMessage, +) +from panel.chat.icon import ChatReactionIcons from panel.layout import Column, Row, Tabs from panel.pane.image import SVG, Image from panel.pane.markup import HTML, Markdown from panel.tests.util import mpl_available, mpl_figure from panel.widgets.button import Button -from panel.widgets.chat import ( - ChatEntry, ChatFeed, ChatInterface, ChatReactionIcons, _FileInputMessage, -) from panel.widgets.indicators import LinearGauge from panel.widgets.input import FileInput, TextAreaInput, TextInput diff --git a/panel/tests/widgets/test_chatbox.py b/panel/tests/widgets/test_chatbox.py index 0dacdb706f..d1096563de 100644 --- a/panel/tests/widgets/test_chatbox.py +++ b/panel/tests/widgets/test_chatbox.py @@ -1,10 +1,10 @@ import pytest +from panel.chatbox import ChatBox, ChatRow from panel.depends import bind from panel.layout import Column from panel.pane import JPG from panel.widgets import FileInput, TextInput -from panel.widgets.chatbox import ChatBox, ChatRow JPG_FILE = "https://assets.holoviz.org/panel/samples/jpg_sample.jpg" diff --git a/panel/widgets/chatbox.py b/panel/widgets/chatbox.py index 5151ea8cfb..cb3564df96 100644 --- a/panel/widgets/chatbox.py +++ b/panel/widgets/chatbox.py @@ -207,7 +207,7 @@ class ChatBox(CompositeWidget): The ChatBox widget displays a conversation between multiple users composed of users' icons, names, messages, and likes. - Reference: https://panel.holoviz.org/reference/chat/ChatBox.html + Reference: https://panel.holoviz.org/reference/widgets/ChatBox.html :Example: From 1347a64adf821dd0b13df244d023e790c1357042 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 10:52:38 -0700 Subject: [PATCH 08/20] Rename and extract out icon test --- .../tests/chat/{test_chat.py => test_base.py} | 60 ----------------- panel/tests/chat/test_icon.py | 65 +++++++++++++++++++ 2 files changed, 65 insertions(+), 60 deletions(-) rename panel/tests/chat/{test_chat.py => test_base.py} (95%) create mode 100644 panel/tests/chat/test_icon.py diff --git a/panel/tests/chat/test_chat.py b/panel/tests/chat/test_base.py similarity index 95% rename from panel/tests/chat/test_chat.py rename to panel/tests/chat/test_base.py index 9b8b7e83dc..1e07c3b37a 100644 --- a/panel/tests/chat/test_chat.py +++ b/panel/tests/chat/test_base.py @@ -28,66 +28,6 @@ } -class TestChatReactionIcons: - def test_init(self): - icons = ChatReactionIcons() - assert icons.options == {"favorite": "heart"} - - svg = icons._svgs[0] - assert isinstance(svg, SVG) - assert svg.alt_text == "favorite" - assert not svg.encode - assert svg.margin == 0 - svg_text = svg.object - assert 'alt="favorite"' in svg_text - assert "icon-tabler-heart" in svg_text - - assert icons._reactions == ["favorite"] - - def test_options(self): - icons = ChatReactionIcons(options={"favorite": "heart", "like": "thumb-up"}) - assert icons.options == {"favorite": "heart", "like": "thumb-up"} - assert len(icons._svgs) == 2 - - svg = icons._svgs[0] - assert svg.alt_text == "favorite" - - svg = icons._svgs[1] - assert svg.alt_text == "like" - - def test_value(self): - icons = ChatReactionIcons(value=["favorite"]) - assert icons.value == ["favorite"] - - svg = icons._svgs[0] - svg_text = svg.object - assert "icon-tabler-heart-fill" in svg_text - - def test_active_icons(self): - icons = ChatReactionIcons( - options={"dislike": "thumb-up"}, - active_icons={"dislike": "thumb-down"}, - value=["dislike"], - ) - assert icons.options == {"dislike": "thumb-up"} - - svg = icons._svgs[0] - svg_text = svg.object - assert "icon-tabler-thumb-down" in svg_text - - icons.value = [] - svg = icons._svgs[0] - svg_text = svg.object - assert "icon-tabler-thumb-up" in svg_text - - def test_width_height(self): - icons = ChatReactionIcons(width=50, height=50) - svg = icons._svgs[0] - svg_text = svg.object - assert 'width="50px"' in svg_text - assert 'height="50px"' in svg_text - - class TestChatEntry: def test_layout(self): entry = ChatEntry(value="ABC") diff --git a/panel/tests/chat/test_icon.py b/panel/tests/chat/test_icon.py new file mode 100644 index 0000000000..2406476773 --- /dev/null +++ b/panel/tests/chat/test_icon.py @@ -0,0 +1,65 @@ + + + +from panel.chat.icon import ChatReactionIcons +from panel.pane.image import SVG + + +class TestChatReactionIcons: + def test_init(self): + icons = ChatReactionIcons() + assert icons.options == {"favorite": "heart"} + + svg = icons._svgs[0] + assert isinstance(svg, SVG) + assert svg.alt_text == "favorite" + assert not svg.encode + assert svg.margin == 0 + svg_text = svg.object + assert 'alt="favorite"' in svg_text + assert "icon-tabler-heart" in svg_text + + assert icons._reactions == ["favorite"] + + def test_options(self): + icons = ChatReactionIcons(options={"favorite": "heart", "like": "thumb-up"}) + assert icons.options == {"favorite": "heart", "like": "thumb-up"} + assert len(icons._svgs) == 2 + + svg = icons._svgs[0] + assert svg.alt_text == "favorite" + + svg = icons._svgs[1] + assert svg.alt_text == "like" + + def test_value(self): + icons = ChatReactionIcons(value=["favorite"]) + assert icons.value == ["favorite"] + + svg = icons._svgs[0] + svg_text = svg.object + assert "icon-tabler-heart-fill" in svg_text + + def test_active_icons(self): + icons = ChatReactionIcons( + options={"dislike": "thumb-up"}, + active_icons={"dislike": "thumb-down"}, + value=["dislike"], + ) + assert icons.options == {"dislike": "thumb-up"} + + svg = icons._svgs[0] + svg_text = svg.object + assert "icon-tabler-thumb-down" in svg_text + + icons.value = [] + svg = icons._svgs[0] + svg_text = svg.object + assert "icon-tabler-thumb-up" in svg_text + + def test_width_height(self): + icons = ChatReactionIcons(width=50, height=50) + svg = icons._svgs[0] + svg_text = svg.object + assert 'width="50px"' in svg_text + assert 'height="50px"' in svg_text From c8386f50277851499aaff3132d32cdff37be1bc2 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 11:10:55 -0700 Subject: [PATCH 09/20] Separate into individual modules --- panel/chat/base.py | 1557 ----------------- panel/chat/entry.py | 570 ++++++ panel/chat/feed.py | 619 +++++++ panel/chat/interface.py | 456 +++++ panel/tests/chat/test_entry.py | 235 +++ .../tests/chat/{test_base.py => test_feed.py} | 437 +---- panel/tests/chat/test_icon.py | 3 - panel/tests/chat/test_interface.py | 205 +++ panel/tests/widgets/test_chatbox.py | 2 +- 9 files changed, 2093 insertions(+), 1991 deletions(-) delete mode 100644 panel/chat/base.py create mode 100644 panel/chat/entry.py create mode 100644 panel/chat/feed.py create mode 100644 panel/chat/interface.py create mode 100644 panel/tests/chat/test_entry.py rename panel/tests/chat/{test_base.py => test_feed.py} (57%) create mode 100644 panel/tests/chat/test_interface.py diff --git a/panel/chat/base.py b/panel/chat/base.py deleted file mode 100644 index 1fe90c53ac..0000000000 --- a/panel/chat/base.py +++ /dev/null @@ -1,1557 +0,0 @@ -"""The chat module provides components for building and using chat interfaces - -For example `ChatEntry`, `ChatFeed` and `ChatInterface`. -""" -from __future__ import annotations - -import asyncio -import datetime -import re -import traceback - -from contextlib import ExitStack -from dataclasses import dataclass -from functools import partial -from inspect import ( - isasyncgen, isasyncgenfunction, isawaitable, isgenerator, -) -from io import BytesIO -from tempfile import NamedTemporaryFile -from typing import ( - Any, BinaryIO, ClassVar, Dict, List, Type, Union, -) - -import param - -from .._param import Margin -from ..io.resources import CDN_DIST -from ..layout import ( - Column, ListPanel, Row, Tabs, -) -from ..layout.card import Card -from ..layout.spacer import VSpacer -from ..pane.base import panel as _panel -from ..pane.image import ( - PDF, SVG, FileBase, Image, ImageBase, -) -from ..pane.markup import HTML, DataFrame, HTMLBasePane -from ..pane.media import Audio, Video -from ..viewable import Viewable -from ..widgets.base import CompositeWidget, Widget -from ..widgets.button import Button -from ..widgets.input import FileInput, TextInput -from .icon import ChatCopyIcon, ChatReactionIcons - -Avatar = Union[str, BytesIO, ImageBase] -AvatarDict = Dict[str, Avatar] - -USER_LOGO = "🧑" -ASSISTANT_LOGO = "🤖" -SYSTEM_LOGO = "⚙️" -ERROR_LOGO = "❌" -GPT_3_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/ChatGPT_logo.svg/1024px-ChatGPT_logo.svg.png?20230318122128" -GPT_4_LOGO = "https://upload.wikimedia.org/wikipedia/commons/a/a4/GPT-4.png" -WOLFRAM_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/WolframCorporateLogo.svg/1920px-WolframCorporateLogo.svg.png" - -DEFAULT_AVATARS = { - # User - "client": USER_LOGO, - "customer": USER_LOGO, - "employee": USER_LOGO, - "human": USER_LOGO, - "person": USER_LOGO, - "user": USER_LOGO, - # Assistant - "agent": ASSISTANT_LOGO, - "ai": ASSISTANT_LOGO, - "assistant": ASSISTANT_LOGO, - "bot": ASSISTANT_LOGO, - "chatbot": ASSISTANT_LOGO, - "machine": ASSISTANT_LOGO, - "robot": ASSISTANT_LOGO, - # System - "system": SYSTEM_LOGO, - "exception": ERROR_LOGO, - "error": ERROR_LOGO, - # Human - "adult": "🧑", - "baby": "👶", - "boy": "👦", - "child": "🧒", - "girl": "👧", - "man": "👨", - "woman": "👩", - # Machine - "chatgpt": GPT_3_LOGO, - "gpt3": GPT_3_LOGO, - "gpt4": GPT_4_LOGO, - "dalle": GPT_4_LOGO, - "openai": GPT_4_LOGO, - "huggingface": "🤗", - "calculator": "🧮", - "langchain": "🦜", - "translator": "🌐", - "wolfram": WOLFRAM_LOGO, - "wolfram alpha": WOLFRAM_LOGO, - # Llama - "llama": "🦙", - "llama2": "🐪", -} - -PLACEHOLDER_SVG = """ - - - - - -""" # noqa: E501 - - -@dataclass -class _FileInputMessage: - """ - A dataclass to hold the contents of a file input message. - - Parameters - ---------- - contents : bytes - The contents of the file. - file_name : str - The name of the file. - mime_type : str - The mime type of the file. - """ - - contents: bytes - file_name: str - mime_type: str - - -@dataclass -class _ChatButtonData: - """ - A dataclass to hold the metadata and data related to the - chat buttons. - - Parameters - ---------- - index : int - The index of the button. - name : str - The name of the button. - icon : str - The icon to display. - objects : List - The objects to display. - buttons : List - The buttons to display. - """ - - index: int - name: str - icon: str - objects: List - buttons: List - - -class ChatEntry(CompositeWidget): - """ - A widget for displaying chat messages 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. - - Associating reactions with messages and mapping them to icons. - - Rendering various content types including text, images, audio, video, and more. - - Reference: https://panel.holoviz.org/reference/chat/ChatEntry.html - - :Example: - - >>> ChatEntry(value="Hello world!", user="New User", avatar="😊") - """ - - avatar = param.ClassSelector( - default="", - class_=(str, BinaryIO, ImageBase), - doc=""" - The avatar to use for the user. Can be a single character text, an emoji, - or anything supported by `pn.pane.Image`. If not set, checks if - the user is available in the default_avatars mapping; else uses the - first character of the name.""", - ) - - avatar_lookup = param.Callable( - default=None, - doc=""" - A function that can lookup an `avatar` from a user name. The function signature should be - `(user: str) -> Avatar`. If this is set, `default_avatars` is disregarded.""", - ) - - css_classes = param.List( - default=["chat-entry"], - doc=""" - The CSS classes to apply to the widget.""", - ) - - default_avatars = param.Dict( - default=DEFAULT_AVATARS, - doc=""" - A default mapping of user names to their corresponding avatars - to use when the user is specified but the avatar is. You can modify, but not replace the - dictionary.""", - ) - - reactions = param.List( - doc=""" - Reactions to associate with the message.""" - ) - - reaction_icons = param.ClassSelector( - class_=(ChatReactionIcons, dict), - doc=""" - A mapping of reactions to their reaction icons; if not provided - defaults to `{"favorite": "heart"}`.""", - ) - - timestamp = param.Date( - doc=""" - Timestamp of the message. Defaults to the creation time.""" - ) - - timestamp_format = param.String(default="%H:%M", doc="The timestamp format.") - - show_avatar = param.Boolean( - default=True, doc="Whether to display the avatar of the user." - ) - - show_user = param.Boolean( - default=True, doc="Whether to display the name of the user." - ) - - show_timestamp = param.Boolean( - default=True, doc="Whether to display the timestamp of the message." - ) - - show_reaction_icons = param.Boolean( - default=True, doc="Whether to display the reaction icons." - ) - - show_copy_icon = param.Boolean( - default=True, doc="Whether to display the copy icon." - ) - - renderers = param.HookList( - doc=""" - A callable or list of callables that accept the value and return a - Panel object to render the value. If a list is provided, will - attempt to use the first renderer that does not raise an - exception. If None, will attempt to infer the renderer - from the value.""" - ) - - user = param.Parameter( - default="User", - doc=""" - Name of the user who sent the message.""", - ) - - value = param.Parameter( - doc=""" - The message contents. Can be any Python object that panel can display.""", - allow_refs=False, - ) - - _value_panel = param.Parameter(doc="The rendered value panel.") - - _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_entry.css"] - - def __init__(self, **params): - from ..param import ParamMethod # circular imports - - self._exit_stack = ExitStack() - - self.chat_copy_icon = ChatCopyIcon( - visible=False, width=15, height=15, css_classes=["copy-icon"] - ) - if params.get("timestamp") is None: - params["timestamp"] = datetime.datetime.utcnow() - if params.get("reaction_icons") is None: - params["reaction_icons"] = {"favorite": "heart"} - if isinstance(params["reaction_icons"], dict): - params["reaction_icons"] = ChatReactionIcons( - options=params["reaction_icons"], width=15, height=15 - ) - super().__init__(**params) - self.reaction_icons.link(self, value="reactions", bidirectional=True) - self.reaction_icons.link( - self, visible="show_reaction_icons", bidirectional=True - ) - self.param.trigger("reactions", "show_reaction_icons") - if not self.avatar: - self.param.trigger("avatar_lookup") - - render_kwargs = {"inplace": True, "stylesheets": self._stylesheets} - left_col = Column( - ParamMethod(self._render_avatar, **render_kwargs), - max_width=60, - height=100, - css_classes=["left"], - stylesheets=self._stylesheets, - visible=self.param.show_avatar, - sizing_mode=None, - ) - center_row = Row( - ParamMethod(self._render_value, **render_kwargs), - self.reaction_icons, - css_classes=["center"], - stylesheets=self._stylesheets, - sizing_mode=None, - ) - right_col = Column( - Row( - ParamMethod(self._render_user, **render_kwargs), - self.chat_copy_icon, - stylesheets=self._stylesheets, - sizing_mode="stretch_width", - ), - center_row, - ParamMethod(self._render_timestamp, **render_kwargs), - css_classes=["right"], - stylesheets=self._stylesheets, - sizing_mode=None, - ) - self._composite.param.update( - stylesheets=self._stylesheets, css_classes=self.css_classes - ) - self._composite[:] = [left_col, right_col] - - @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) - - def _select_renderer( - self, - contents: Any, - mime_type: str, - ): - """ - Determine the renderer to use based on the mime type. - """ - renderer = _panel - if mime_type == "application/pdf": - contents = self._exit_stack.enter_context(BytesIO(contents)) - renderer = partial(PDF, embed=True) - elif mime_type.startswith("audio/"): - file = self._exit_stack.enter_context( - NamedTemporaryFile(suffix=".mp3", delete=False) - ) - file.write(contents) - file.seek(0) - contents = file.name - renderer = Audio - elif mime_type.startswith("video/"): - contents = self._exit_stack.enter_context(BytesIO(contents)) - renderer = Video - elif mime_type.startswith("image/"): - contents = self._exit_stack.enter_context(BytesIO(contents)) - renderer = Image - elif mime_type.endswith("/csv"): - import pandas as pd - - with BytesIO(contents) as buf: - contents = pd.read_csv(buf) - renderer = DataFrame - elif mime_type.startswith("text"): - if isinstance(contents, bytes): - contents = contents.decode("utf-8") - return contents, renderer - - def _set_default_attrs(self, obj): - """ - Set the sizing mode and height of the object. - """ - if hasattr(obj, "objects"): - obj._stylesheets = self._stylesheets - for subobj in obj.objects: - self._set_default_attrs(subobj) - return None - - is_markup = isinstance(obj, HTMLBasePane) and not isinstance(obj, FileBase) - if is_markup: - if len(str(obj.object)) > 0: # only show a background if there is content - obj.css_classes = [*obj.css_classes, "message"] - obj.sizing_mode = None - else: - if obj.sizing_mode is None and not obj.width: - obj.sizing_mode = "stretch_width" - - if obj.height is None: - obj.height = 500 - return obj - - @staticmethod - def _is_widget_renderer(renderer): - return isinstance(renderer, type) and issubclass(renderer, Widget) - - def _create_panel(self, value): - """ - Create a panel object from the value. - """ - if isinstance(value, Viewable): - return value - - renderer = _panel - if isinstance(value, _FileInputMessage): - contents = value.contents - mime_type = value.mime_type - value, renderer = self._select_renderer(contents, mime_type) - else: - try: - import magic - - mime_type = magic.from_buffer(value, mime=True) - value, renderer = self._select_renderer(value, mime_type) - except Exception: - pass - - renderers = self.renderers.copy() or [] - renderers.append(renderer) - for renderer in renderers: - try: - if self._is_widget_renderer(renderer): - value_panel = renderer(value=value) - else: - value_panel = renderer(value) - if isinstance(value_panel, Viewable): - break - except Exception: - pass - else: - value_panel = _panel(value) - - self._set_default_attrs(value_panel) - return value_panel - - @param.depends("avatar", "show_avatar") - def _render_avatar(self) -> HTML | Image: - """ - Render the avatar pane as some HTML text or Image pane. - """ - avatar = self.avatar - if not avatar and self.user: - avatar = self.user[0] - - if isinstance(avatar, ImageBase): - avatar_pane = avatar - avatar_pane.param.update(width=35, height=35) - elif len(avatar) == 1: - # single character - avatar_pane = HTML(avatar) - else: - try: - avatar_pane = Image(avatar, width=35, height=35) - except ValueError: - # likely an emoji - avatar_pane = HTML(avatar) - avatar_pane.css_classes = ["avatar", *avatar_pane.css_classes] - avatar_pane.visible = self.show_avatar - return avatar_pane - - @param.depends("user", "show_user") - def _render_user(self) -> HTML: - """ - Render the user pane as some HTML text or Image pane. - """ - return HTML(self.user, height=20, css_classes=["name"], visible=self.show_user) - - @param.depends("value") - def _render_value(self) -> Viewable: - """ - Renders value as a panel object. - """ - value = self.value - value_panel = self._create_panel(value) - - # used in ChatFeed to extract its contents - self._value_panel = value_panel - return value_panel - - @param.depends("timestamp", "timestamp_format", "show_timestamp") - def _render_timestamp(self) -> HTML: - """ - Formats the timestamp and renders it as HTML pane. - """ - return HTML( - self.timestamp.strftime(self.timestamp_format), - css_classes=["timestamp"], - visible=self.show_timestamp, - ) - - @param.depends("avatar_lookup", "user", watch=True) - def _update_avatar(self): - """ - Update the avatar based on the user name. - - We do not use on_init here because if avatar is set, - we don't want to override the provided avatar. - - However, if the user is updated, we want to update the avatar. - """ - if self.avatar_lookup: - self.avatar = self.avatar_lookup(self.user) - else: - self.avatar = self._avatar_lookup(self.user) - - @param.depends("_value_panel", watch=True) - def _update_chat_copy_icon(self): - value = self._value_panel - if isinstance(value, HTMLBasePane): - value = value.object - if isinstance(value, str) and self.show_copy_icon: - self.chat_copy_icon.value = value - self.chat_copy_icon.visible = True - else: - self.chat_copy_icon.value = "" - self.chat_copy_icon.visible = False - - def _cleanup(self, root=None) -> None: - """ - Cleanup the exit stack. - """ - if self._exit_stack is not None: - self._exit_stack.close() - self._exit_stack = None - super()._cleanup() - - def stream(self, token: str): - """ - Updates the entry with the new token traversing the value 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. - """ - i = -1 - parent_panel = None - value_panel = self - attr = "value" - value = self.value - while not isinstance(value, str) or isinstance(value_panel, ImageBase): - value_panel = value - if hasattr(value, "objects"): - parent_panel = value - attr = "objects" - value = value.objects[i] - i = -1 - elif hasattr(value, "object"): - attr = "object" - value = value.object - elif hasattr(value, "value"): - attr = "value" - value = value.value - elif parent_panel is not None: - value = parent_panel - parent_panel = None - i -= 1 - setattr(value_panel, attr, value + token) - - def update( - self, - value: dict | ChatEntry | Any, - user: str | None = None, - avatar: str | BinaryIO | None = None, - ): - """ - Updates the entry with a new value, user and avatar. - - Arguments - --------- - value : ChatEntry | dict | Any - The message contents to send. - user : str | None - The user to send as; overrides the message entry's user if provided. - avatar : str | BinaryIO | None - The avatar to use; overrides the message entry's avatar if provided. - """ - updates = {} - if isinstance(value, dict): - updates.update(value) - if user: - updates["user"] = user - if avatar: - updates["avatar"] = avatar - elif isinstance(value, ChatEntry): - if user is not None or avatar is not None: - raise ValueError( - "Cannot set user or avatar when explicitly sending " - "a ChatEntry. Set them directly on the ChatEntry." - ) - updates = value.param.values() - else: - updates["value"] = value - self.param.update(**updates) - - -class ChatFeed(CompositeWidget): - """ - A widget to display a list of `ChatEntry` objects and interact with them. - - This widget provides methods to: - - Send (append) messages to the chat log. - - Stream tokens to the latest `ChatEntry` in the chat log. - - Execute callbacks when a user sends a message. - - Undo a number of sent `ChatEntry` objects. - - Clear the chat log of all `ChatEntry` objects. - - Reference: https://panel.holoviz.org/reference/chat/ChatFeed.html - - :Example: - - >>> async def say_welcome(contents, user, instance): - >>> yield "Welcome!" - >>> yield "Glad you're here!" - - >>> chat_feed = ChatFeed(callback=say_welcome, header="Welcome Feed") - >>> chat_feed.send("Hello World!", user="New User", avatar="😊") - """ - - callback = param.Callable( - allow_refs=False, - doc=""" - Callback to execute when a user sends a message or - when `respond` is called. The signature must include - the previous message value `contents`, the previous `user` name, - and the component `instance`.""", - ) - - callback_exception = param.ObjectSelector( - default="summary", - objects=["raise", "summary", "verbose", "ignore"], - doc=""" - How to handle exceptions raised by the callback. - If "raise", the exception will be raised. - If "summary", a summary will be sent to the chat feed. - If "verbose", the full traceback will be sent to the chat feed. - If "ignore", the exception will be ignored. - """, - ) - - callback_user = param.String( - default="Assistant", - doc=""" - The default user name to use for the entry provided by the callback.""", - ) - - card_params = param.Dict( - default={}, - doc=""" - Params to pass to Card, like `header`, - `header_background`, `header_color`, etc.""", - ) - - entry_params = param.Dict( - default={}, - doc=""" - Params to pass to each ChatEntry, like `reaction_icons`, `timestamp_format`, - `show_avatar`, `show_user`, and `show_timestamp`.""", - ) - - header = param.Parameter( - doc=""" - The header of the chat feed; commonly used for the title. - Can be a string, pane, or widget.""" - ) - - margin = Margin( - default=5, - 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).""", - ) - - renderers = param.HookList( - doc=""" - A callable or list of callables that accept the value and return a - Panel object to render the value. If a list is provided, will - attempt to use the first renderer that does not raise an - exception. If None, will attempt to infer the renderer - from the value.""" - ) - - placeholder_text = param.String( - default="", - doc=""" - If placeholder is the default LoadingSpinner, - the text to display next to it.""", - ) - - placeholder_threshold = param.Number( - default=1, - bounds=(0, None), - doc=""" - Min duration in seconds of buffering before displaying the placeholder. - If 0, the placeholder will be disabled.""", - ) - - auto_scroll_limit = param.Integer( - default=200, - bounds=(0, None), - doc=""" - Max pixel distance from the latest object in the Column to - activate automatic scrolling upon update. Setting to 0 - disables auto-scrolling.""", - ) - - scroll_button_threshold = param.Integer( - default=100, - bounds=(0, None), - doc=""" - Min pixel distance from the latest object in the Column to - display the scroll button. Setting to 0 - disables the scroll button.""", - ) - - view_latest = param.Boolean( - default=True, - doc=""" - Whether to scroll to the latest object on init. If not - enabled the view will be on the first object.""", - ) - value = param.List( - item_type=ChatEntry, - doc=""" - The list of entries in the feed.""", - ) - - _placeholder = param.ClassSelector( - class_=ChatEntry, - allow_refs=False, - doc=""" - The placeholder wrapped in a ChatEntry object; - primarily to prevent recursion error in _update_placeholder.""", - ) - - _disabled = param.Boolean( - default=False, - doc=""" - Whether the chat feed is disabled.""", - ) - - _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_feed.css"] - - _composite_type: ClassVar[Type[ListPanel]] = Card - - def __init__(self, **params): - if params.get("renderers") and not isinstance(params["renderers"], list): - params["renderers"] = [params["renderers"]] - super().__init__(**params) - # instantiate the card - card_params = { - "header": self.header, - "hide_header": self.header is None, - "collapsed": False, - "collapsible": False, - "css_classes": ["chat-feed"], - "header_css_classes": ["chat-feed-header"], - "title_css_classes": ["chat-feed-title"], - "sizing_mode": self.sizing_mode, - "height": self.height, - "width": self.width, - "max_width": self.max_width, - "max_height": self.max_height, - "styles": { - "border": "1px solid var(--panel-border-color, #e1e1e1)", - "padding": "0px", - }, - "stylesheets": self._stylesheets, - } - card_params.update(**self.card_params) - if self.sizing_mode is None: - card_params["height"] = card_params.get("height", 500) - self._composite.param.update(**card_params) - - # instantiate the card's column - chat_log_params = { - p: getattr(self, p) - for p in Column.param - if (p in ChatFeed.param and p != "name" and getattr(self, p) is not None) - } - chat_log_params["css_classes"] = ["chat-feed-log"] - chat_log_params["stylesheets"] = self._stylesheets - chat_log_params["objects"] = self.value - chat_log_params["margin"] = 0 - self._chat_log = Column(**chat_log_params) - self._composite[:] = [self._chat_log, VSpacer()] - - # handle async callbacks using this trick - self._callback_trigger = Button(visible=False) - self._callback_trigger.on_click(self._prepare_response) - - self.link(self._chat_log, value="objects", bidirectional=True) - - @param.depends("placeholder_text", watch=True, on_init=True) - def _update_placeholder(self): - loading_avatar = SVG( - PLACEHOLDER_SVG, sizing_mode=None, css_classes=["rotating-placeholder"] - ) - self._placeholder = ChatEntry( - user=" ", - value=self.placeholder_text, - show_timestamp=False, - avatar=loading_avatar, - reaction_icons={}, - show_copy_icon=False, - ) - - @param.depends("header", watch=True) - def _hide_header(self): - """ - Hide the header if there is no title or header. - """ - self._composite.hide_header = not self.header - - def _replace_placeholder(self, entry: ChatEntry | None = None) -> None: - """ - Replace the placeholder from the chat log with the entry - if placeholder, otherwise simply append the entry. - Replacing helps lessen the chat log jumping around. - """ - index = None - if self.placeholder_threshold > 0: - try: - index = self.value.index(self._placeholder) - except ValueError: - pass - - if index is not None: - if entry is not None: - self._chat_log[index] = entry - elif entry is None: - self._chat_log.remove(self._placeholder) - elif entry is not None: - self._chat_log.append(entry) - - def _build_entry( - self, - value: dict, - user: str | None = None, - avatar: str | BinaryIO | None = None, - ) -> ChatEntry | None: - """ - Builds a ChatEntry from the value. - """ - if "value" not in value: - raise ValueError( - f"If 'value' is a dict, it must contain a 'value' key, " - f"e.g. {{'value': 'Hello World'}}; got {value!r}" - ) - entry_params = dict(value, renderers=self.renderers, **self.entry_params) - if user: - entry_params["user"] = user - if avatar: - entry_params["avatar"] = avatar - if self.width: - entry_params["width"] = int(self.width - 80) - entry = ChatEntry(**entry_params) - return entry - - def _upsert_entry( - self, value: Any, entry: ChatEntry | None = None - ) -> ChatEntry | None: - """ - Replace the placeholder entry with the response or update - the entry's value with the response. - """ - if value is None: - # don't add new entry if the callback returns None - return - - user = self.callback_user - avatar = None - if isinstance(value, dict): - user = value.get("user", user) - avatar = value.get("avatar") - if entry is not None: - entry.update(value, user=user, avatar=avatar) - return entry - elif isinstance(value, ChatEntry): - return value - - if not isinstance(value, dict): - value = {"value": value} - new_entry = self._build_entry(value, user=user, avatar=avatar) - self._replace_placeholder(new_entry) - return new_entry - - def _extract_contents(self, entry: ChatEntry) -> Any: - """ - Extracts the contents from the entry's panel object. - """ - value = entry._value_panel - if hasattr(value, "object"): - contents = value.object - elif hasattr(value, "objects"): - contents = value.objects - elif hasattr(value, "value"): - contents = value.value - else: - contents = value - return contents - - async def _serialize_response(self, response: Any) -> ChatEntry | None: - """ - Serializes the response by iterating over it and - updating the entry's value. - """ - response_entry = None - if isasyncgen(response): - async for token in response: - response_entry = self._upsert_entry(token, response_entry) - elif isgenerator(response): - for token in response: - response_entry = self._upsert_entry(token, response_entry) - elif isawaitable(response): - response_entry = self._upsert_entry(await response, response_entry) - else: - response_entry = self._upsert_entry(response, response_entry) - return response_entry - - async def _handle_callback(self, entry: ChatEntry) -> ChatEntry | None: - contents = self._extract_contents(entry) - response = self.callback(contents, entry.user, self) - response_entry = await self._serialize_response(response) - return response_entry - - async def _schedule_placeholder( - self, - task: asyncio.Task, - num_entries: int, - ) -> None: - """ - Schedules the placeholder to be added to the chat log - if the callback takes longer than the placeholder threshold. - """ - if self.placeholder_threshold == 0: - return - - callable_is_async = asyncio.iscoroutinefunction( - self.callback - ) or isasyncgenfunction(self.callback) - start = asyncio.get_event_loop().time() - while not task.done() and num_entries == len(self._chat_log): - duration = asyncio.get_event_loop().time() - start - if duration > self.placeholder_threshold or not callable_is_async: - self._chat_log.append(self._placeholder) - return - await asyncio.sleep(0.28) - - async def _prepare_response(self, _) -> None: - """ - Prepares the response by scheduling the placeholder and - executing the callback. - """ - if self.callback is None: - return - - disabled = self.disabled - try: - self.disabled = True - entry = self._chat_log[-1] - if not isinstance(entry, ChatEntry): - return - - num_entries = len(self._chat_log) - task = asyncio.create_task(self._handle_callback(entry)) - await self._schedule_placeholder(task, num_entries) - await task - task.result() - except Exception as e: - send_kwargs = dict(user="Exception", respond=False) - if self.callback_exception == "summary": - self.send(str(e), **send_kwargs) - elif self.callback_exception == "verbose": - self.send(f"```python\n{traceback.format_exc()}\n```", **send_kwargs) - elif self.callback_exception == "ignore": - return - else: - raise e - finally: - self._replace_placeholder(None) - self.disabled = disabled - - # Public API - - def send( - self, - value: ChatEntry | dict | Any, - user: str | None = None, - avatar: str | BinaryIO | None = None, - respond: bool = True, - ) -> ChatEntry | None: - """ - Sends a value and creates a new entry in the chat log. - - If `respond` is `True`, additionally executes the callback, if provided. - - Arguments - --------- - value : ChatEntry | dict | Any - The message contents to send. - user : str | None - The user to send as; overrides the message entry's user if provided. - avatar : str | BinaryIO | None - The avatar to use; overrides the message entry's avatar if provided. - respond : bool - Whether to execute the callback. - - Returns - ------- - The entry that was created. - """ - if isinstance(value, ChatEntry): - if user is not None or avatar is not None: - raise ValueError( - "Cannot set user or avatar when explicitly sending " - "a ChatEntry. Set them directly on the ChatEntry." - ) - entry = value - else: - if not isinstance(value, dict): - value = {"value": value} - entry = self._build_entry(value, user=user, avatar=avatar) - self._chat_log.append(entry) - if respond: - self.respond() - return entry - - def stream( - self, - value: str, - user: str | None = None, - avatar: str | BinaryIO | None = None, - entry: ChatEntry | None = None, - ) -> ChatEntry | None: - """ - Streams a token and updates the provided entry, if provided. - Otherwise creates a new entry in the chat log, so be sure the - returned entry is passed back into the method, e.g. - `entry = chat.stream(token, entry=entry)`. - - This method is primarily for outputs that are not generators-- - notably LangChain. For most cases, use the send method instead. - - Arguments - --------- - value : str | dict | ChatEntry - The new token value to stream. - user : str | None - The user to stream as; overrides the entry's user if provided. - avatar : str | BinaryIO | None - The avatar to use; overrides the entry's avatar if provided. - entry : ChatEntry | None - The entry to update. - - Returns - ------- - The entry that was updated. - """ - if isinstance(value, ChatEntry) and (user is not None or avatar is not None): - raise ValueError( - "Cannot set user or avatar when explicitly streaming " - "a ChatEntry. Set them directly on the ChatEntry." - ) - elif entry: - if isinstance(value, (str, dict)): - entry.stream(value) - if user: - entry.user = user - if avatar: - entry.avatar = avatar - else: - entry.update(value, user=user, avatar=avatar) - return entry - - if isinstance(value, ChatEntry): - entry = value - else: - if not isinstance(value, dict): - value = {"value": value} - entry = self._build_entry(value, user=user, avatar=avatar) - self._replace_placeholder(entry) - return entry - - def respond(self): - """ - Executes the callback with the latest entry in the chat log. - """ - self._callback_trigger.param.trigger("clicks") - - def undo(self, count: int = 1) -> List[Any]: - """ - Removes the last `count` of entries from the chat log and returns them. - - Parameters - ---------- - count : int - The number of entries to remove, starting from the last entry. - - Returns - ------- - The entries that were removed. - """ - if count <= 0: - return [] - entries = self._chat_log.objects - undone_entries = entries[-count:] - self._chat_log.objects = entries[:-count] - return undone_entries - - def clear(self) -> List[Any]: - """ - Clears the chat log and returns the entries that were cleared. - - Returns - ------- - The entries that were cleared. - """ - cleared_entries = self._chat_log.objects - self._chat_log.clear() - return cleared_entries - - -class ChatInterface(ChatFeed): - """ - High level widget that contains the chat log and the chat input. - - Reference: https://panel.holoviz.org/reference/chat/ChatInterface.html - - :Example: - - >>> async def repeat_contents(contents, user, instance): - >>> yield contents - - >>> chat_interface = ChatInterface( - callback=repeat_contents, widgets=[TextInput(), FileInput()] - ) - """ - - auto_send_types = param.List( - doc=""" - The widget types to automatically send when the user presses enter - or clicks away from the widget. If not provided, defaults to - `[TextInput]`.""" - ) - - avatar = param.ClassSelector( - class_=(str, BinaryIO), - doc=""" - The avatar to use for the user. Can be a single character text, an emoji, - or anything supported by `pn.pane.Image`. If not set, uses the - first character of the name.""", - ) - - reset_on_send = param.Boolean( - default=False, - doc=""" - Whether to reset the widget's value after sending a message; - has no effect for `TextInput`.""", - ) - - show_send = param.Boolean( - default=True, - doc=""" - Whether to show the send button.""", - ) - - show_rerun = param.Boolean( - default=True, - doc=""" - Whether to show the rerun button.""", - ) - - show_undo = param.Boolean( - default=True, - doc=""" - Whether to show the undo button.""", - ) - - show_clear = param.Boolean( - default=True, - doc=""" - Whether to show the clear button.""", - ) - - show_button_name = param.Boolean( - default=None, - doc=""" - Whether to show the button name.""", - ) - - user = param.String(default="User", doc="Name of the ChatInterface user.") - - widgets = param.ClassSelector( - class_=(Widget, list), - allow_refs=False, - doc=""" - Widgets to use for the input. If not provided, defaults to - `[TextInput]`.""", - ) - - _widgets = param.Dict( - default={}, - allow_refs=False, - doc=""" - The input widgets.""", - ) - - _input_container = param.ClassSelector( - class_=Row, - doc=""" - The input message row that wraps the input layout (Tabs / Row) - to easily swap between Tabs and Rows, depending on - number of widgets.""", - ) - - _input_layout = param.ClassSelector( - class_=(Row, Tabs), - doc=""" - The input layout that contains the input widgets.""", - ) - - _button_data = param.Dict( - default={}, - doc=""" - Metadata and data related to the buttons.""", - ) - - _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_interface.css"] - - def __init__(self, **params): - widgets = params.get("widgets") - if widgets is None: - params["widgets"] = [TextInput(placeholder="Send a message")] - elif not isinstance(widgets, list): - params["widgets"] = [widgets] - active = params.pop("active", None) - super().__init__(**params) - - button_icons = { - "send": "send", - "rerun": "repeat-once", - "undo": "arrow-back", - "clear": "trash", - } - for action in list(button_icons): - if not getattr(self, f"show_{action}", True): - button_icons.pop(action) - self._button_data = { - name: _ChatButtonData( - index=index, name=name, icon=icon, objects=[], buttons=[] - ) - for index, (name, icon) in enumerate(button_icons.items()) - } - self._input_container = Row( - css_classes=["chat-interface-input-container"], - stylesheets=self._stylesheets, - ) - self._update_input_width() - self._init_widgets() - if active is not None: - self.active = active - self._composite.param.update( - objects=self._composite.objects + [self._input_container], - css_classes=["chat-interface"], - stylesheets=self._stylesheets, - ) - - def _link_disabled_loading(self, obj: Viewable): - """ - Link the disabled and loading attributes of the chat box to the - given object. - """ - for attr in ["disabled", "loading"]: - setattr(obj, attr, getattr(self, attr)) - self.link(obj, **{attr: attr}) - - @param.depends("width", watch=True) - def _update_input_width(self): - """ - Update the input width. - """ - self.show_button_name = self.width is None or self.width >= 400 - - @param.depends( - "width", - "widgets", - "show_send", - "show_rerun", - "show_undo", - "show_clear", - "show_button_name", - watch=True, - ) - def _init_widgets(self): - """ - Initialize the input widgets. - - Returns - ------- - The input widgets. - """ - widgets = self.widgets - if isinstance(self.widgets, Widget): - widgets = [self.widgets] - - self._widgets = {} - for widget in widgets: - key = widget.name or widget.__class__.__name__ - if isinstance(widget, type): # check if instantiated - widget = widget() - self._widgets[key] = widget - - sizing_mode = self.sizing_mode - if sizing_mode is not None: - if "both" in sizing_mode or "scale_height" in sizing_mode: - sizing_mode = "stretch_width" - elif "height" in sizing_mode: - sizing_mode = None - input_layout = Tabs( - sizing_mode=sizing_mode, - css_classes=["chat-interface-input-tabs"], - stylesheets=self._stylesheets, - dynamic=True, - ) - for name, widget in self._widgets.items(): - # for longer form messages, like TextArea / Ace, don't - # submit when clicking away; only if they manually click - # the send button - auto_send_types = tuple(self.auto_send_types) or (TextInput,) - if isinstance(widget, auto_send_types): - widget.param.watch(self._click_send, "value") - widget.param.update( - sizing_mode="stretch_width", css_classes=["chat-interface-input-widget"] - ) - - buttons = [] - for button_data in self._button_data.values(): - if self.show_button_name: - button_name = button_data.name.title() - else: - button_name = "" - button = Button( - name=button_name, - icon=button_data.icon, - sizing_mode="stretch_width", - max_width=90 if self.show_button_name else 45, - max_height=50, - margin=(5, 5, 5, 0), - align="center", - ) - self._link_disabled_loading(button) - action = button_data.name - button.on_click(getattr(self, f"_click_{action}")) - buttons.append(button) - button_data.buttons.append(button) - - message_row = Row( - widget, - *buttons, - sizing_mode="stretch_width", - css_classes=["chat-interface-input-row"], - stylesheets=self._stylesheets, - align="center", - ) - input_layout.append((name, message_row)) - - # if only a single input, don't use tabs - if len(self._widgets) == 1: - input_layout = input_layout[0] - else: - self._chat_log.css_classes = ["chat-feed-log-tabbed"] - - self._input_container.objects = [input_layout] - self._input_layout = input_layout - - def _click_send(self, _: param.parameterized.Event | None = None) -> None: - """ - Send the input when the user presses Enter. - """ - # wait until the chat feed's callback is done executing - # before allowing another input - if self.disabled: - return - - active_widget = self.active_widget - value = active_widget.value - if value: - if isinstance(active_widget, FileInput): - value = _FileInputMessage( - contents=value, - mime_type=active_widget.mime_type, - file_name=active_widget.filename, - ) - # don't use isinstance here; TextAreaInput subclasses TextInput - if type(active_widget) is TextInput or self.reset_on_send: - updates = {"value": ""} - if hasattr(active_widget, "value_input"): - updates["value_input"] = "" - try: - active_widget.param.update(updates) - except ValueError: - pass - else: - return # no message entered - self._reset_button_data() - self.send(value=value, user=self.user, avatar=self.avatar, respond=True) - - def _get_last_user_entry_index(self) -> int: - """ - Get the index of the last user entry. - """ - entries = self.value[::-1] - for index, entry in enumerate(entries, 1): - if entry.user == self.user: - return index - return 0 - - def _toggle_revert(self, button_data: _ChatButtonData, active: bool): - """ - Toggle the button's icon and name to indicate - whether the action can be reverted. - """ - for button in button_data.buttons: - if active and button_data.objects: - button_update = { - "button_type": "warning", - "name": "Revert", - "width": 90, - } - else: - button_update = { - "button_type": "default", - "name": button_data.name.title() if self.show_button_name else "", - "width": 90 if self.show_button_name else 45, - } - button.param.update(button_update) - - def _reset_button_data(self): - """ - Clears all the objects in the button data - to prevent reverting. - """ - for button_data in self._button_data.values(): - button_data.objects.clear() - self._toggle_revert(button_data, False) - - def _click_rerun(self, _): - """ - Upon clicking the rerun button, rerun the last user entry, - which can trigger the callback again. - """ - count = self._get_last_user_entry_index() - entries = self.undo(count) - if not entries: - return - self.send(value=entries[0], respond=True) - - def _click_undo(self, _): - """ - Upon clicking the undo button, undo (remove) entries - up to the last user entry. If the button is clicked - again without performing any other actions, revert the undo. - """ - undo_data = self._button_data["undo"] - undo_objects = undo_data.objects - if not undo_objects: - self._reset_button_data() - count = self._get_last_user_entry_index() - undo_data.objects = self.undo(count) - self._toggle_revert(undo_data, True) - else: - self.value = [*self.value, *undo_objects.copy()] - self._reset_button_data() - - def _click_clear(self, _): - """ - Upon clicking the clear button, clear the chat log. - If the button is clicked again without performing any - other actions, revert the clear. - """ - clear_data = self._button_data["clear"] - clear_objects = clear_data.objects - if not clear_objects: - self._reset_button_data() - clear_data.objects = self.clear() - self._toggle_revert(clear_data, True) - else: - self.value = clear_objects.copy() - self._reset_button_data() - - @property - def active_widget(self) -> Widget: - """ - The currently active widget. - - Returns - ------- - The active widget. - """ - if isinstance(self._input_layout, Tabs): - return self._input_layout[self.active].objects[0] - return self._input_layout.objects[0] - - @property - def active(self) -> int: - """ - The currently active input widget tab index; - -1 if there is only one widget available - which is not in a tab. - - Returns - ------- - The active input widget tab index. - """ - if isinstance(self._input_layout, Tabs): - return self._input_layout.active - return -1 - - @active.setter - def active(self, index: int) -> None: - """ - Set the active input widget tab index. - - Arguments - --------- - index : int - The active index to set. - """ - if isinstance(self._input_layout, Tabs): - self._input_layout.active = index diff --git a/panel/chat/entry.py b/panel/chat/entry.py new file mode 100644 index 0000000000..f62b044f4b --- /dev/null +++ b/panel/chat/entry.py @@ -0,0 +1,570 @@ +"""The entry module provides a low-level API for rendering chat messages. +""" +from __future__ import annotations + +import datetime +import re + +from contextlib import ExitStack +from dataclasses import dataclass +from functools import partial +from io import BytesIO +from tempfile import NamedTemporaryFile +from typing import ( + Any, BinaryIO, ClassVar, Dict, List, Union, +) + +import param + +from ..io.resources import CDN_DIST +from ..layout import Column, Row +from ..pane.base import panel as _panel +from ..pane.image import ( + PDF, FileBase, Image, ImageBase, +) +from ..pane.markup import HTML, DataFrame, HTMLBasePane +from ..pane.media import Audio, Video +from ..viewable import Viewable +from ..widgets.base import CompositeWidget, Widget +from .icon import ChatCopyIcon, ChatReactionIcons + +Avatar = Union[str, BytesIO, ImageBase] +AvatarDict = Dict[str, Avatar] + +USER_LOGO = "🧑" +ASSISTANT_LOGO = "🤖" +SYSTEM_LOGO = "⚙️" +ERROR_LOGO = "❌" +GPT_3_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/ChatGPT_logo.svg/1024px-ChatGPT_logo.svg.png?20230318122128" +GPT_4_LOGO = "https://upload.wikimedia.org/wikipedia/commons/a/a4/GPT-4.png" +WOLFRAM_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/WolframCorporateLogo.svg/1920px-WolframCorporateLogo.svg.png" + +DEFAULT_AVATARS = { + # User + "client": USER_LOGO, + "customer": USER_LOGO, + "employee": USER_LOGO, + "human": USER_LOGO, + "person": USER_LOGO, + "user": USER_LOGO, + # Assistant + "agent": ASSISTANT_LOGO, + "ai": ASSISTANT_LOGO, + "assistant": ASSISTANT_LOGO, + "bot": ASSISTANT_LOGO, + "chatbot": ASSISTANT_LOGO, + "machine": ASSISTANT_LOGO, + "robot": ASSISTANT_LOGO, + # System + "system": SYSTEM_LOGO, + "exception": ERROR_LOGO, + "error": ERROR_LOGO, + # Human + "adult": "🧑", + "baby": "👶", + "boy": "👦", + "child": "🧒", + "girl": "👧", + "man": "👨", + "woman": "👩", + # Machine + "chatgpt": GPT_3_LOGO, + "gpt3": GPT_3_LOGO, + "gpt4": GPT_4_LOGO, + "dalle": GPT_4_LOGO, + "openai": GPT_4_LOGO, + "huggingface": "🤗", + "calculator": "🧮", + "langchain": "🦜", + "translator": "🌐", + "wolfram": WOLFRAM_LOGO, + "wolfram alpha": WOLFRAM_LOGO, + # Llama + "llama": "🦙", + "llama2": "🐪", +} + + +@dataclass +class _FileInputMessage: + """ + A dataclass to hold the contents of a file input message. + + Parameters + ---------- + contents : bytes + The contents of the file. + file_name : str + The name of the file. + mime_type : str + The mime type of the file. + """ + + contents: bytes + file_name: str + mime_type: str + + +class ChatEntry(CompositeWidget): + """ + A widget for displaying chat messages 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. + - Associating reactions with messages and mapping them to icons. + - Rendering various content types including text, images, audio, video, and more. + + Reference: https://panel.holoviz.org/reference/chat/ChatEntry.html + + :Example: + + >>> ChatEntry(value="Hello world!", user="New User", avatar="😊") + """ + + avatar = param.ClassSelector( + default="", + class_=(str, BinaryIO, ImageBase), + doc=""" + The avatar to use for the user. Can be a single character text, an emoji, + or anything supported by `pn.pane.Image`. If not set, checks if + the user is available in the default_avatars mapping; else uses the + first character of the name.""", + ) + + avatar_lookup = param.Callable( + default=None, + doc=""" + A function that can lookup an `avatar` from a user name. The function signature should be + `(user: str) -> Avatar`. If this is set, `default_avatars` is disregarded.""", + ) + + css_classes = param.List( + default=["chat-entry"], + doc=""" + The CSS classes to apply to the widget.""", + ) + + default_avatars = param.Dict( + default=DEFAULT_AVATARS, + doc=""" + A default mapping of user names to their corresponding avatars + to use when the user is specified but the avatar is. You can modify, but not replace the + dictionary.""", + ) + + reactions = param.List( + doc=""" + Reactions to associate with the message.""" + ) + + reaction_icons = param.ClassSelector( + class_=(ChatReactionIcons, dict), + doc=""" + A mapping of reactions to their reaction icons; if not provided + defaults to `{"favorite": "heart"}`.""", + ) + + timestamp = param.Date( + doc=""" + Timestamp of the message. Defaults to the creation time.""" + ) + + timestamp_format = param.String(default="%H:%M", doc="The timestamp format.") + + show_avatar = param.Boolean( + default=True, doc="Whether to display the avatar of the user." + ) + + show_user = param.Boolean( + default=True, doc="Whether to display the name of the user." + ) + + show_timestamp = param.Boolean( + default=True, doc="Whether to display the timestamp of the message." + ) + + show_reaction_icons = param.Boolean( + default=True, doc="Whether to display the reaction icons." + ) + + show_copy_icon = param.Boolean( + default=True, doc="Whether to display the copy icon." + ) + + renderers = param.HookList( + doc=""" + A callable or list of callables that accept the value and return a + Panel object to render the value. If a list is provided, will + attempt to use the first renderer that does not raise an + exception. If None, will attempt to infer the renderer + from the value.""" + ) + + user = param.Parameter( + default="User", + doc=""" + Name of the user who sent the message.""", + ) + + value = param.Parameter( + doc=""" + The message contents. Can be any Python object that panel can display.""", + allow_refs=False, + ) + + _value_panel = param.Parameter(doc="The rendered value panel.") + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_entry.css"] + + def __init__(self, **params): + from ..param import ParamMethod # circular imports + + self._exit_stack = ExitStack() + + self.chat_copy_icon = ChatCopyIcon( + visible=False, width=15, height=15, css_classes=["copy-icon"] + ) + if params.get("timestamp") is None: + params["timestamp"] = datetime.datetime.utcnow() + if params.get("reaction_icons") is None: + params["reaction_icons"] = {"favorite": "heart"} + if isinstance(params["reaction_icons"], dict): + params["reaction_icons"] = ChatReactionIcons( + options=params["reaction_icons"], width=15, height=15 + ) + super().__init__(**params) + self.reaction_icons.link(self, value="reactions", bidirectional=True) + self.reaction_icons.link( + self, visible="show_reaction_icons", bidirectional=True + ) + self.param.trigger("reactions", "show_reaction_icons") + if not self.avatar: + self.param.trigger("avatar_lookup") + + render_kwargs = {"inplace": True, "stylesheets": self._stylesheets} + left_col = Column( + ParamMethod(self._render_avatar, **render_kwargs), + max_width=60, + height=100, + css_classes=["left"], + stylesheets=self._stylesheets, + visible=self.param.show_avatar, + sizing_mode=None, + ) + center_row = Row( + ParamMethod(self._render_value, **render_kwargs), + self.reaction_icons, + css_classes=["center"], + stylesheets=self._stylesheets, + sizing_mode=None, + ) + right_col = Column( + Row( + ParamMethod(self._render_user, **render_kwargs), + self.chat_copy_icon, + stylesheets=self._stylesheets, + sizing_mode="stretch_width", + ), + center_row, + ParamMethod(self._render_timestamp, **render_kwargs), + css_classes=["right"], + stylesheets=self._stylesheets, + sizing_mode=None, + ) + self._composite.param.update( + stylesheets=self._stylesheets, css_classes=self.css_classes + ) + self._composite[:] = [left_col, right_col] + + @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) + + def _select_renderer( + self, + contents: Any, + mime_type: str, + ): + """ + Determine the renderer to use based on the mime type. + """ + renderer = _panel + if mime_type == "application/pdf": + contents = self._exit_stack.enter_context(BytesIO(contents)) + renderer = partial(PDF, embed=True) + elif mime_type.startswith("audio/"): + file = self._exit_stack.enter_context( + NamedTemporaryFile(suffix=".mp3", delete=False) + ) + file.write(contents) + file.seek(0) + contents = file.name + renderer = Audio + elif mime_type.startswith("video/"): + contents = self._exit_stack.enter_context(BytesIO(contents)) + renderer = Video + elif mime_type.startswith("image/"): + contents = self._exit_stack.enter_context(BytesIO(contents)) + renderer = Image + elif mime_type.endswith("/csv"): + import pandas as pd + + with BytesIO(contents) as buf: + contents = pd.read_csv(buf) + renderer = DataFrame + elif mime_type.startswith("text"): + if isinstance(contents, bytes): + contents = contents.decode("utf-8") + return contents, renderer + + def _set_default_attrs(self, obj): + """ + Set the sizing mode and height of the object. + """ + if hasattr(obj, "objects"): + obj._stylesheets = self._stylesheets + for subobj in obj.objects: + self._set_default_attrs(subobj) + return None + + is_markup = isinstance(obj, HTMLBasePane) and not isinstance(obj, FileBase) + if is_markup: + if len(str(obj.object)) > 0: # only show a background if there is content + obj.css_classes = [*obj.css_classes, "message"] + obj.sizing_mode = None + else: + if obj.sizing_mode is None and not obj.width: + obj.sizing_mode = "stretch_width" + + if obj.height is None: + obj.height = 500 + return obj + + @staticmethod + def _is_widget_renderer(renderer): + return isinstance(renderer, type) and issubclass(renderer, Widget) + + def _create_panel(self, value): + """ + Create a panel object from the value. + """ + if isinstance(value, Viewable): + return value + + renderer = _panel + if isinstance(value, _FileInputMessage): + contents = value.contents + mime_type = value.mime_type + value, renderer = self._select_renderer(contents, mime_type) + else: + try: + import magic + + mime_type = magic.from_buffer(value, mime=True) + value, renderer = self._select_renderer(value, mime_type) + except Exception: + pass + + renderers = self.renderers.copy() or [] + renderers.append(renderer) + for renderer in renderers: + try: + if self._is_widget_renderer(renderer): + value_panel = renderer(value=value) + else: + value_panel = renderer(value) + if isinstance(value_panel, Viewable): + break + except Exception: + pass + else: + value_panel = _panel(value) + + self._set_default_attrs(value_panel) + return value_panel + + @param.depends("avatar", "show_avatar") + def _render_avatar(self) -> HTML | Image: + """ + Render the avatar pane as some HTML text or Image pane. + """ + avatar = self.avatar + if not avatar and self.user: + avatar = self.user[0] + + if isinstance(avatar, ImageBase): + avatar_pane = avatar + avatar_pane.param.update(width=35, height=35) + elif len(avatar) == 1: + # single character + avatar_pane = HTML(avatar) + else: + try: + avatar_pane = Image(avatar, width=35, height=35) + except ValueError: + # likely an emoji + avatar_pane = HTML(avatar) + avatar_pane.css_classes = ["avatar", *avatar_pane.css_classes] + avatar_pane.visible = self.show_avatar + return avatar_pane + + @param.depends("user", "show_user") + def _render_user(self) -> HTML: + """ + Render the user pane as some HTML text or Image pane. + """ + return HTML(self.user, height=20, css_classes=["name"], visible=self.show_user) + + @param.depends("value") + def _render_value(self) -> Viewable: + """ + Renders value as a panel object. + """ + value = self.value + value_panel = self._create_panel(value) + + # used in ChatFeed to extract its contents + self._value_panel = value_panel + return value_panel + + @param.depends("timestamp", "timestamp_format", "show_timestamp") + def _render_timestamp(self) -> HTML: + """ + Formats the timestamp and renders it as HTML pane. + """ + return HTML( + self.timestamp.strftime(self.timestamp_format), + css_classes=["timestamp"], + visible=self.show_timestamp, + ) + + @param.depends("avatar_lookup", "user", watch=True) + def _update_avatar(self): + """ + Update the avatar based on the user name. + + We do not use on_init here because if avatar is set, + we don't want to override the provided avatar. + + However, if the user is updated, we want to update the avatar. + """ + if self.avatar_lookup: + self.avatar = self.avatar_lookup(self.user) + else: + self.avatar = self._avatar_lookup(self.user) + + @param.depends("_value_panel", watch=True) + def _update_chat_copy_icon(self): + value = self._value_panel + if isinstance(value, HTMLBasePane): + value = value.object + if isinstance(value, str) and self.show_copy_icon: + self.chat_copy_icon.value = value + self.chat_copy_icon.visible = True + else: + self.chat_copy_icon.value = "" + self.chat_copy_icon.visible = False + + def _cleanup(self, root=None) -> None: + """ + Cleanup the exit stack. + """ + if self._exit_stack is not None: + self._exit_stack.close() + self._exit_stack = None + super()._cleanup() + + def stream(self, token: str): + """ + Updates the entry with the new token traversing the value 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. + """ + i = -1 + parent_panel = None + value_panel = self + attr = "value" + value = self.value + while not isinstance(value, str) or isinstance(value_panel, ImageBase): + value_panel = value + if hasattr(value, "objects"): + parent_panel = value + attr = "objects" + value = value.objects[i] + i = -1 + elif hasattr(value, "object"): + attr = "object" + value = value.object + elif hasattr(value, "value"): + attr = "value" + value = value.value + elif parent_panel is not None: + value = parent_panel + parent_panel = None + i -= 1 + setattr(value_panel, attr, value + token) + + def update( + self, + value: dict | ChatEntry | Any, + user: str | None = None, + avatar: str | BinaryIO | None = None, + ): + """ + Updates the entry with a new value, user and avatar. + + Arguments + --------- + value : ChatEntry | dict | Any + The message contents to send. + user : str | None + The user to send as; overrides the message entry's user if provided. + avatar : str | BinaryIO | None + The avatar to use; overrides the message entry's avatar if provided. + """ + updates = {} + if isinstance(value, dict): + updates.update(value) + if user: + updates["user"] = user + if avatar: + updates["avatar"] = avatar + elif isinstance(value, ChatEntry): + if user is not None or avatar is not None: + raise ValueError( + "Cannot set user or avatar when explicitly sending " + "a ChatEntry. Set them directly on the ChatEntry." + ) + updates = value.param.values() + else: + updates["value"] = value + self.param.update(**updates) diff --git a/panel/chat/feed.py b/panel/chat/feed.py new file mode 100644 index 0000000000..b8bb86a44e --- /dev/null +++ b/panel/chat/feed.py @@ -0,0 +1,619 @@ +""" +A widget to display a list of `ChatEntry` objects. +""" +from __future__ import annotations + +import asyncio +import traceback + +from inspect import ( + isasyncgen, isasyncgenfunction, isawaitable, isgenerator, +) +from io import BytesIO +from typing import ( + Any, BinaryIO, ClassVar, Dict, List, Type, Union, +) + +import param + +from .._param import Margin +from ..io.resources import CDN_DIST +from ..layout import Column, ListPanel +from ..layout.card import Card +from ..layout.spacer import VSpacer +from ..pane.image import SVG, ImageBase +from ..widgets.base import CompositeWidget +from ..widgets.button import Button +from .entry import ChatEntry + +Avatar = Union[str, BytesIO, ImageBase] +AvatarDict = Dict[str, Avatar] + +USER_LOGO = "🧑" +ASSISTANT_LOGO = "🤖" +SYSTEM_LOGO = "⚙️" +ERROR_LOGO = "❌" +GPT_3_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/ChatGPT_logo.svg/1024px-ChatGPT_logo.svg.png?20230318122128" +GPT_4_LOGO = "https://upload.wikimedia.org/wikipedia/commons/a/a4/GPT-4.png" +WOLFRAM_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/WolframCorporateLogo.svg/1920px-WolframCorporateLogo.svg.png" + +DEFAULT_AVATARS = { + # User + "client": USER_LOGO, + "customer": USER_LOGO, + "employee": USER_LOGO, + "human": USER_LOGO, + "person": USER_LOGO, + "user": USER_LOGO, + # Assistant + "agent": ASSISTANT_LOGO, + "ai": ASSISTANT_LOGO, + "assistant": ASSISTANT_LOGO, + "bot": ASSISTANT_LOGO, + "chatbot": ASSISTANT_LOGO, + "machine": ASSISTANT_LOGO, + "robot": ASSISTANT_LOGO, + # System + "system": SYSTEM_LOGO, + "exception": ERROR_LOGO, + "error": ERROR_LOGO, + # Human + "adult": "🧑", + "baby": "👶", + "boy": "👦", + "child": "🧒", + "girl": "👧", + "man": "👨", + "woman": "👩", + # Machine + "chatgpt": GPT_3_LOGO, + "gpt3": GPT_3_LOGO, + "gpt4": GPT_4_LOGO, + "dalle": GPT_4_LOGO, + "openai": GPT_4_LOGO, + "huggingface": "🤗", + "calculator": "🧮", + "langchain": "🦜", + "translator": "🌐", + "wolfram": WOLFRAM_LOGO, + "wolfram alpha": WOLFRAM_LOGO, + # Llama + "llama": "🦙", + "llama2": "🐪", +} + +PLACEHOLDER_SVG = """ + + + + + +""" # noqa: E501 + + +class ChatFeed(CompositeWidget): + """ + A widget to display a list of `ChatEntry` objects and interact with them. + + This widget provides methods to: + - Send (append) messages to the chat log. + - Stream tokens to the latest `ChatEntry` in the chat log. + - Execute callbacks when a user sends a message. + - Undo a number of sent `ChatEntry` objects. + - Clear the chat log of all `ChatEntry` objects. + + Reference: https://panel.holoviz.org/reference/chat/ChatFeed.html + + :Example: + + >>> async def say_welcome(contents, user, instance): + >>> yield "Welcome!" + >>> yield "Glad you're here!" + + >>> chat_feed = ChatFeed(callback=say_welcome, header="Welcome Feed") + >>> chat_feed.send("Hello World!", user="New User", avatar="😊") + """ + + callback = param.Callable( + allow_refs=False, + doc=""" + Callback to execute when a user sends a message or + when `respond` is called. The signature must include + the previous message value `contents`, the previous `user` name, + and the component `instance`.""", + ) + + callback_exception = param.ObjectSelector( + default="summary", + objects=["raise", "summary", "verbose", "ignore"], + doc=""" + How to handle exceptions raised by the callback. + If "raise", the exception will be raised. + If "summary", a summary will be sent to the chat feed. + If "verbose", the full traceback will be sent to the chat feed. + If "ignore", the exception will be ignored. + """, + ) + + callback_user = param.String( + default="Assistant", + doc=""" + The default user name to use for the entry provided by the callback.""", + ) + + card_params = param.Dict( + default={}, + doc=""" + Params to pass to Card, like `header`, + `header_background`, `header_color`, etc.""", + ) + + entry_params = param.Dict( + default={}, + doc=""" + Params to pass to each ChatEntry, like `reaction_icons`, `timestamp_format`, + `show_avatar`, `show_user`, and `show_timestamp`.""", + ) + + header = param.Parameter( + doc=""" + The header of the chat feed; commonly used for the title. + Can be a string, pane, or widget.""" + ) + + margin = Margin( + default=5, + 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).""", + ) + + renderers = param.HookList( + doc=""" + A callable or list of callables that accept the value and return a + Panel object to render the value. If a list is provided, will + attempt to use the first renderer that does not raise an + exception. If None, will attempt to infer the renderer + from the value.""" + ) + + placeholder_text = param.String( + default="", + doc=""" + If placeholder is the default LoadingSpinner, + the text to display next to it.""", + ) + + placeholder_threshold = param.Number( + default=1, + bounds=(0, None), + doc=""" + Min duration in seconds of buffering before displaying the placeholder. + If 0, the placeholder will be disabled.""", + ) + + auto_scroll_limit = param.Integer( + default=200, + bounds=(0, None), + doc=""" + Max pixel distance from the latest object in the Column to + activate automatic scrolling upon update. Setting to 0 + disables auto-scrolling.""", + ) + + scroll_button_threshold = param.Integer( + default=100, + bounds=(0, None), + doc=""" + Min pixel distance from the latest object in the Column to + display the scroll button. Setting to 0 + disables the scroll button.""", + ) + + view_latest = param.Boolean( + default=True, + doc=""" + Whether to scroll to the latest object on init. If not + enabled the view will be on the first object.""", + ) + value = param.List( + item_type=ChatEntry, + doc=""" + The list of entries in the feed.""", + ) + + _placeholder = param.ClassSelector( + class_=ChatEntry, + allow_refs=False, + doc=""" + The placeholder wrapped in a ChatEntry object; + primarily to prevent recursion error in _update_placeholder.""", + ) + + _disabled = param.Boolean( + default=False, + doc=""" + Whether the chat feed is disabled.""", + ) + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_feed.css"] + + _composite_type: ClassVar[Type[ListPanel]] = Card + + def __init__(self, **params): + if params.get("renderers") and not isinstance(params["renderers"], list): + params["renderers"] = [params["renderers"]] + super().__init__(**params) + # instantiate the card + card_params = { + "header": self.header, + "hide_header": self.header is None, + "collapsed": False, + "collapsible": False, + "css_classes": ["chat-feed"], + "header_css_classes": ["chat-feed-header"], + "title_css_classes": ["chat-feed-title"], + "sizing_mode": self.sizing_mode, + "height": self.height, + "width": self.width, + "max_width": self.max_width, + "max_height": self.max_height, + "styles": { + "border": "1px solid var(--panel-border-color, #e1e1e1)", + "padding": "0px", + }, + "stylesheets": self._stylesheets, + } + card_params.update(**self.card_params) + if self.sizing_mode is None: + card_params["height"] = card_params.get("height", 500) + self._composite.param.update(**card_params) + + # instantiate the card's column + chat_log_params = { + p: getattr(self, p) + for p in Column.param + if (p in ChatFeed.param and p != "name" and getattr(self, p) is not None) + } + chat_log_params["css_classes"] = ["chat-feed-log"] + chat_log_params["stylesheets"] = self._stylesheets + chat_log_params["objects"] = self.value + chat_log_params["margin"] = 0 + self._chat_log = Column(**chat_log_params) + self._composite[:] = [self._chat_log, VSpacer()] + + # handle async callbacks using this trick + self._callback_trigger = Button(visible=False) + self._callback_trigger.on_click(self._prepare_response) + + self.link(self._chat_log, value="objects", bidirectional=True) + + @param.depends("placeholder_text", watch=True, on_init=True) + def _update_placeholder(self): + loading_avatar = SVG( + PLACEHOLDER_SVG, sizing_mode=None, css_classes=["rotating-placeholder"] + ) + self._placeholder = ChatEntry( + user=" ", + value=self.placeholder_text, + show_timestamp=False, + avatar=loading_avatar, + reaction_icons={}, + show_copy_icon=False, + ) + + @param.depends("header", watch=True) + def _hide_header(self): + """ + Hide the header if there is no title or header. + """ + self._composite.hide_header = not self.header + + def _replace_placeholder(self, entry: ChatEntry | None = None) -> None: + """ + Replace the placeholder from the chat log with the entry + if placeholder, otherwise simply append the entry. + Replacing helps lessen the chat log jumping around. + """ + index = None + if self.placeholder_threshold > 0: + try: + index = self.value.index(self._placeholder) + except ValueError: + pass + + if index is not None: + if entry is not None: + self._chat_log[index] = entry + elif entry is None: + self._chat_log.remove(self._placeholder) + elif entry is not None: + self._chat_log.append(entry) + + def _build_entry( + self, + value: dict, + user: str | None = None, + avatar: str | BinaryIO | None = None, + ) -> ChatEntry | None: + """ + Builds a ChatEntry from the value. + """ + if "value" not in value: + raise ValueError( + f"If 'value' is a dict, it must contain a 'value' key, " + f"e.g. {{'value': 'Hello World'}}; got {value!r}" + ) + entry_params = dict(value, renderers=self.renderers, **self.entry_params) + if user: + entry_params["user"] = user + if avatar: + entry_params["avatar"] = avatar + if self.width: + entry_params["width"] = int(self.width - 80) + entry = ChatEntry(**entry_params) + return entry + + def _upsert_entry( + self, value: Any, entry: ChatEntry | None = None + ) -> ChatEntry | None: + """ + Replace the placeholder entry with the response or update + the entry's value with the response. + """ + if value is None: + # don't add new entry if the callback returns None + return + + user = self.callback_user + avatar = None + if isinstance(value, dict): + user = value.get("user", user) + avatar = value.get("avatar") + if entry is not None: + entry.update(value, user=user, avatar=avatar) + return entry + elif isinstance(value, ChatEntry): + return value + + if not isinstance(value, dict): + value = {"value": value} + new_entry = self._build_entry(value, user=user, avatar=avatar) + self._replace_placeholder(new_entry) + return new_entry + + def _extract_contents(self, entry: ChatEntry) -> Any: + """ + Extracts the contents from the entry's panel object. + """ + value = entry._value_panel + if hasattr(value, "object"): + contents = value.object + elif hasattr(value, "objects"): + contents = value.objects + elif hasattr(value, "value"): + contents = value.value + else: + contents = value + return contents + + async def _serialize_response(self, response: Any) -> ChatEntry | None: + """ + Serializes the response by iterating over it and + updating the entry's value. + """ + response_entry = None + if isasyncgen(response): + async for token in response: + response_entry = self._upsert_entry(token, response_entry) + elif isgenerator(response): + for token in response: + response_entry = self._upsert_entry(token, response_entry) + elif isawaitable(response): + response_entry = self._upsert_entry(await response, response_entry) + else: + response_entry = self._upsert_entry(response, response_entry) + return response_entry + + async def _handle_callback(self, entry: ChatEntry) -> ChatEntry | None: + contents = self._extract_contents(entry) + response = self.callback(contents, entry.user, self) + response_entry = await self._serialize_response(response) + return response_entry + + async def _schedule_placeholder( + self, + task: asyncio.Task, + num_entries: int, + ) -> None: + """ + Schedules the placeholder to be added to the chat log + if the callback takes longer than the placeholder threshold. + """ + if self.placeholder_threshold == 0: + return + + callable_is_async = asyncio.iscoroutinefunction( + self.callback + ) or isasyncgenfunction(self.callback) + start = asyncio.get_event_loop().time() + while not task.done() and num_entries == len(self._chat_log): + duration = asyncio.get_event_loop().time() - start + if duration > self.placeholder_threshold or not callable_is_async: + self._chat_log.append(self._placeholder) + return + await asyncio.sleep(0.28) + + async def _prepare_response(self, _) -> None: + """ + Prepares the response by scheduling the placeholder and + executing the callback. + """ + if self.callback is None: + return + + disabled = self.disabled + try: + self.disabled = True + entry = self._chat_log[-1] + if not isinstance(entry, ChatEntry): + return + + num_entries = len(self._chat_log) + task = asyncio.create_task(self._handle_callback(entry)) + await self._schedule_placeholder(task, num_entries) + await task + task.result() + except Exception as e: + send_kwargs = dict(user="Exception", respond=False) + if self.callback_exception == "summary": + self.send(str(e), **send_kwargs) + elif self.callback_exception == "verbose": + self.send(f"```python\n{traceback.format_exc()}\n```", **send_kwargs) + elif self.callback_exception == "ignore": + return + else: + raise e + finally: + self._replace_placeholder(None) + self.disabled = disabled + + # Public API + + def send( + self, + value: ChatEntry | dict | Any, + user: str | None = None, + avatar: str | BinaryIO | None = None, + respond: bool = True, + ) -> ChatEntry | None: + """ + Sends a value and creates a new entry in the chat log. + + If `respond` is `True`, additionally executes the callback, if provided. + + Arguments + --------- + value : ChatEntry | dict | Any + The message contents to send. + user : str | None + The user to send as; overrides the message entry's user if provided. + avatar : str | BinaryIO | None + The avatar to use; overrides the message entry's avatar if provided. + respond : bool + Whether to execute the callback. + + Returns + ------- + The entry that was created. + """ + if isinstance(value, ChatEntry): + if user is not None or avatar is not None: + raise ValueError( + "Cannot set user or avatar when explicitly sending " + "a ChatEntry. Set them directly on the ChatEntry." + ) + entry = value + else: + if not isinstance(value, dict): + value = {"value": value} + entry = self._build_entry(value, user=user, avatar=avatar) + self._chat_log.append(entry) + if respond: + self.respond() + return entry + + def stream( + self, + value: str, + user: str | None = None, + avatar: str | BinaryIO | None = None, + entry: ChatEntry | None = None, + ) -> ChatEntry | None: + """ + Streams a token and updates the provided entry, if provided. + Otherwise creates a new entry in the chat log, so be sure the + returned entry is passed back into the method, e.g. + `entry = chat.stream(token, entry=entry)`. + + This method is primarily for outputs that are not generators-- + notably LangChain. For most cases, use the send method instead. + + Arguments + --------- + value : str | dict | ChatEntry + The new token value to stream. + user : str | None + The user to stream as; overrides the entry's user if provided. + avatar : str | BinaryIO | None + The avatar to use; overrides the entry's avatar if provided. + entry : ChatEntry | None + The entry to update. + + Returns + ------- + The entry that was updated. + """ + if isinstance(value, ChatEntry) and (user is not None or avatar is not None): + raise ValueError( + "Cannot set user or avatar when explicitly streaming " + "a ChatEntry. Set them directly on the ChatEntry." + ) + elif entry: + if isinstance(value, (str, dict)): + entry.stream(value) + if user: + entry.user = user + if avatar: + entry.avatar = avatar + else: + entry.update(value, user=user, avatar=avatar) + return entry + + if isinstance(value, ChatEntry): + entry = value + else: + if not isinstance(value, dict): + value = {"value": value} + entry = self._build_entry(value, user=user, avatar=avatar) + self._replace_placeholder(entry) + return entry + + def respond(self): + """ + Executes the callback with the latest entry in the chat log. + """ + self._callback_trigger.param.trigger("clicks") + + def undo(self, count: int = 1) -> List[Any]: + """ + Removes the last `count` of entries from the chat log and returns them. + + Parameters + ---------- + count : int + The number of entries to remove, starting from the last entry. + + Returns + ------- + The entries that were removed. + """ + if count <= 0: + return [] + entries = self._chat_log.objects + undone_entries = entries[-count:] + self._chat_log.objects = entries[:-count] + return undone_entries + + def clear(self) -> List[Any]: + """ + Clears the chat log and returns the entries that were cleared. + + Returns + ------- + The entries that were cleared. + """ + cleared_entries = self._chat_log.objects + self._chat_log.clear() + return cleared_entries diff --git a/panel/chat/interface.py b/panel/chat/interface.py new file mode 100644 index 0000000000..02a820174f --- /dev/null +++ b/panel/chat/interface.py @@ -0,0 +1,456 @@ +""" +A widget to display a list of `ChatEntry` objects and interact with them. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import BinaryIO, ClassVar, List + +import param + +from ..io.resources import CDN_DIST +from ..layout import Row, Tabs +from ..viewable import Viewable +from ..widgets.base import Widget +from ..widgets.button import Button +from ..widgets.input import FileInput, TextInput +from .entry import _FileInputMessage +from .feed import ChatFeed + + +@dataclass +class _ChatButtonData: + """ + A dataclass to hold the metadata and data related to the + chat buttons. + + Parameters + ---------- + index : int + The index of the button. + name : str + The name of the button. + icon : str + The icon to display. + objects : List + The objects to display. + buttons : List + The buttons to display. + """ + + index: int + name: str + icon: str + objects: List + buttons: List + + +class ChatInterface(ChatFeed): + """ + High level widget that contains the chat log and the chat input. + + Reference: https://panel.holoviz.org/reference/chat/ChatInterface.html + + :Example: + + >>> async def repeat_contents(contents, user, instance): + >>> yield contents + + >>> chat_interface = ChatInterface( + callback=repeat_contents, widgets=[TextInput(), FileInput()] + ) + """ + + auto_send_types = param.List( + doc=""" + The widget types to automatically send when the user presses enter + or clicks away from the widget. If not provided, defaults to + `[TextInput]`.""" + ) + + avatar = param.ClassSelector( + class_=(str, BinaryIO), + doc=""" + The avatar to use for the user. Can be a single character text, an emoji, + or anything supported by `pn.pane.Image`. If not set, uses the + first character of the name.""", + ) + + reset_on_send = param.Boolean( + default=False, + doc=""" + Whether to reset the widget's value after sending a message; + has no effect for `TextInput`.""", + ) + + show_send = param.Boolean( + default=True, + doc=""" + Whether to show the send button.""", + ) + + show_rerun = param.Boolean( + default=True, + doc=""" + Whether to show the rerun button.""", + ) + + show_undo = param.Boolean( + default=True, + doc=""" + Whether to show the undo button.""", + ) + + show_clear = param.Boolean( + default=True, + doc=""" + Whether to show the clear button.""", + ) + + show_button_name = param.Boolean( + default=None, + doc=""" + Whether to show the button name.""", + ) + + user = param.String(default="User", doc="Name of the ChatInterface user.") + + widgets = param.ClassSelector( + class_=(Widget, list), + allow_refs=False, + doc=""" + Widgets to use for the input. If not provided, defaults to + `[TextInput]`.""", + ) + + _widgets = param.Dict( + default={}, + allow_refs=False, + doc=""" + The input widgets.""", + ) + + _input_container = param.ClassSelector( + class_=Row, + doc=""" + The input message row that wraps the input layout (Tabs / Row) + to easily swap between Tabs and Rows, depending on + number of widgets.""", + ) + + _input_layout = param.ClassSelector( + class_=(Row, Tabs), + doc=""" + The input layout that contains the input widgets.""", + ) + + _button_data = param.Dict( + default={}, + doc=""" + Metadata and data related to the buttons.""", + ) + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_interface.css"] + + def __init__(self, **params): + widgets = params.get("widgets") + if widgets is None: + params["widgets"] = [TextInput(placeholder="Send a message")] + elif not isinstance(widgets, list): + params["widgets"] = [widgets] + active = params.pop("active", None) + super().__init__(**params) + + button_icons = { + "send": "send", + "rerun": "repeat-once", + "undo": "arrow-back", + "clear": "trash", + } + for action in list(button_icons): + if not getattr(self, f"show_{action}", True): + button_icons.pop(action) + self._button_data = { + name: _ChatButtonData( + index=index, name=name, icon=icon, objects=[], buttons=[] + ) + for index, (name, icon) in enumerate(button_icons.items()) + } + self._input_container = Row( + css_classes=["chat-interface-input-container"], + stylesheets=self._stylesheets, + ) + self._update_input_width() + self._init_widgets() + if active is not None: + self.active = active + self._composite.param.update( + objects=self._composite.objects + [self._input_container], + css_classes=["chat-interface"], + stylesheets=self._stylesheets, + ) + + def _link_disabled_loading(self, obj: Viewable): + """ + Link the disabled and loading attributes of the chat box to the + given object. + """ + for attr in ["disabled", "loading"]: + setattr(obj, attr, getattr(self, attr)) + self.link(obj, **{attr: attr}) + + @param.depends("width", watch=True) + def _update_input_width(self): + """ + Update the input width. + """ + self.show_button_name = self.width is None or self.width >= 400 + + @param.depends( + "width", + "widgets", + "show_send", + "show_rerun", + "show_undo", + "show_clear", + "show_button_name", + watch=True, + ) + def _init_widgets(self): + """ + Initialize the input widgets. + + Returns + ------- + The input widgets. + """ + widgets = self.widgets + if isinstance(self.widgets, Widget): + widgets = [self.widgets] + + self._widgets = {} + for widget in widgets: + key = widget.name or widget.__class__.__name__ + if isinstance(widget, type): # check if instantiated + widget = widget() + self._widgets[key] = widget + + sizing_mode = self.sizing_mode + if sizing_mode is not None: + if "both" in sizing_mode or "scale_height" in sizing_mode: + sizing_mode = "stretch_width" + elif "height" in sizing_mode: + sizing_mode = None + input_layout = Tabs( + sizing_mode=sizing_mode, + css_classes=["chat-interface-input-tabs"], + stylesheets=self._stylesheets, + dynamic=True, + ) + for name, widget in self._widgets.items(): + # for longer form messages, like TextArea / Ace, don't + # submit when clicking away; only if they manually click + # the send button + auto_send_types = tuple(self.auto_send_types) or (TextInput,) + if isinstance(widget, auto_send_types): + widget.param.watch(self._click_send, "value") + widget.param.update( + sizing_mode="stretch_width", css_classes=["chat-interface-input-widget"] + ) + + buttons = [] + for button_data in self._button_data.values(): + if self.show_button_name: + button_name = button_data.name.title() + else: + button_name = "" + button = Button( + name=button_name, + icon=button_data.icon, + sizing_mode="stretch_width", + max_width=90 if self.show_button_name else 45, + max_height=50, + margin=(5, 5, 5, 0), + align="center", + ) + self._link_disabled_loading(button) + action = button_data.name + button.on_click(getattr(self, f"_click_{action}")) + buttons.append(button) + button_data.buttons.append(button) + + message_row = Row( + widget, + *buttons, + sizing_mode="stretch_width", + css_classes=["chat-interface-input-row"], + stylesheets=self._stylesheets, + align="center", + ) + input_layout.append((name, message_row)) + + # if only a single input, don't use tabs + if len(self._widgets) == 1: + input_layout = input_layout[0] + else: + self._chat_log.css_classes = ["chat-feed-log-tabbed"] + + self._input_container.objects = [input_layout] + self._input_layout = input_layout + + def _click_send(self, _: param.parameterized.Event | None = None) -> None: + """ + Send the input when the user presses Enter. + """ + # wait until the chat feed's callback is done executing + # before allowing another input + if self.disabled: + return + + active_widget = self.active_widget + value = active_widget.value + if value: + if isinstance(active_widget, FileInput): + value = _FileInputMessage( + contents=value, + mime_type=active_widget.mime_type, + file_name=active_widget.filename, + ) + # don't use isinstance here; TextAreaInput subclasses TextInput + if type(active_widget) is TextInput or self.reset_on_send: + updates = {"value": ""} + if hasattr(active_widget, "value_input"): + updates["value_input"] = "" + try: + active_widget.param.update(updates) + except ValueError: + pass + else: + return # no message entered + self._reset_button_data() + self.send(value=value, user=self.user, avatar=self.avatar, respond=True) + + def _get_last_user_entry_index(self) -> int: + """ + Get the index of the last user entry. + """ + entries = self.value[::-1] + for index, entry in enumerate(entries, 1): + if entry.user == self.user: + return index + return 0 + + def _toggle_revert(self, button_data: _ChatButtonData, active: bool): + """ + Toggle the button's icon and name to indicate + whether the action can be reverted. + """ + for button in button_data.buttons: + if active and button_data.objects: + button_update = { + "button_type": "warning", + "name": "Revert", + "width": 90, + } + else: + button_update = { + "button_type": "default", + "name": button_data.name.title() if self.show_button_name else "", + "width": 90 if self.show_button_name else 45, + } + button.param.update(button_update) + + def _reset_button_data(self): + """ + Clears all the objects in the button data + to prevent reverting. + """ + for button_data in self._button_data.values(): + button_data.objects.clear() + self._toggle_revert(button_data, False) + + def _click_rerun(self, _): + """ + Upon clicking the rerun button, rerun the last user entry, + which can trigger the callback again. + """ + count = self._get_last_user_entry_index() + entries = self.undo(count) + if not entries: + return + self.send(value=entries[0], respond=True) + + def _click_undo(self, _): + """ + Upon clicking the undo button, undo (remove) entries + up to the last user entry. If the button is clicked + again without performing any other actions, revert the undo. + """ + undo_data = self._button_data["undo"] + undo_objects = undo_data.objects + if not undo_objects: + self._reset_button_data() + count = self._get_last_user_entry_index() + undo_data.objects = self.undo(count) + self._toggle_revert(undo_data, True) + else: + self.value = [*self.value, *undo_objects.copy()] + self._reset_button_data() + + def _click_clear(self, _): + """ + Upon clicking the clear button, clear the chat log. + If the button is clicked again without performing any + other actions, revert the clear. + """ + clear_data = self._button_data["clear"] + clear_objects = clear_data.objects + if not clear_objects: + self._reset_button_data() + clear_data.objects = self.clear() + self._toggle_revert(clear_data, True) + else: + self.value = clear_objects.copy() + self._reset_button_data() + + @property + def active_widget(self) -> Widget: + """ + The currently active widget. + + Returns + ------- + The active widget. + """ + if isinstance(self._input_layout, Tabs): + return self._input_layout[self.active].objects[0] + return self._input_layout.objects[0] + + @property + def active(self) -> int: + """ + The currently active input widget tab index; + -1 if there is only one widget available + which is not in a tab. + + Returns + ------- + The active input widget tab index. + """ + if isinstance(self._input_layout, Tabs): + return self._input_layout.active + return -1 + + @active.setter + def active(self, index: int) -> None: + """ + Set the active input widget tab index. + + Arguments + --------- + index : int + The active index to set. + """ + if isinstance(self._input_layout, Tabs): + self._input_layout.active = index diff --git a/panel/tests/chat/test_entry.py b/panel/tests/chat/test_entry.py new file mode 100644 index 0000000000..098e9ae70d --- /dev/null +++ b/panel/tests/chat/test_entry.py @@ -0,0 +1,235 @@ +import datetime + +import pytest + +from panel import Param, bind +from panel.chat.entry import ChatEntry, _FileInputMessage +from panel.chat.icon import ChatReactionIcons +from panel.layout import Column, Row +from panel.pane.image import SVG, Image +from panel.pane.markup import HTML, Markdown +from panel.tests.util import mpl_available, mpl_figure +from panel.widgets.button import Button +from panel.widgets.input import FileInput, TextAreaInput, TextInput + + +class TestChatEntry: + def test_layout(self): + entry = ChatEntry(value="ABC") + columns = entry._composite.objects + assert len(columns) == 2 + + avatar_pane = columns[0][0].object() + assert isinstance(avatar_pane, HTML) + assert avatar_pane.object == "🧑" + + row = columns[1][0] + user_pane = row[0].object() + assert isinstance(user_pane, HTML) + assert user_pane.object == "User" + + center_row = columns[1][1] + assert isinstance(center_row, Row) + + value_pane = center_row[0].object() + assert isinstance(value_pane, Markdown) + assert value_pane.object == "ABC" + + icons = center_row[1] + assert isinstance(icons, ChatReactionIcons) + + timestamp_pane = columns[1][2].object() + assert isinstance(timestamp_pane, HTML) + + def test_reactions_link(self): + # on init + entry = ChatEntry(reactions=["favorite"]) + assert entry.reaction_icons.value == ["favorite"] + + # on change in entry + entry.reactions = [] + assert entry.reaction_icons.value == [] + + # on change in reaction_icons + entry.reactions = ["favorite"] + assert entry.reaction_icons.value == ["favorite"] + + def test_reaction_icons_input_dict(self): + entry = ChatEntry(reaction_icons={"favorite": "heart"}) + assert isinstance(entry.reaction_icons, ChatReactionIcons) + assert entry.reaction_icons.options == {"favorite": "heart"} + + def test_update_avatar(self): + entry = ChatEntry(avatar="A") + columns = entry._composite.objects + avatar_pane = columns[0][0].object() + assert isinstance(avatar_pane, HTML) + assert avatar_pane.object == "A" + + entry.avatar = "B" + avatar_pane = columns[0][0].object() + assert avatar_pane.object == "B" + + entry.avatar = "❤️" + avatar_pane = columns[0][0].object() + assert avatar_pane.object == "❤️" + + entry.avatar = "https://assets.holoviz.org/panel/samples/jpg_sample.jpg" + avatar_pane = columns[0][0].object() + assert isinstance(avatar_pane, Image) + assert ( + avatar_pane.object + == "https://assets.holoviz.org/panel/samples/jpg_sample.jpg" + ) + + entry.show_avatar = False + avatar_pane = columns[0][0].object() + assert not avatar_pane.visible + + entry.avatar = SVG("https://tabler-icons.io/static/tabler-icons/icons/user.svg") + avatar_pane = columns[0][0].object() + assert isinstance(avatar_pane, SVG) + + def test_update_user(self): + entry = ChatEntry(user="Andrew") + columns = entry._composite.objects + user_pane = columns[1][0][0].object() + assert isinstance(user_pane, HTML) + assert user_pane.object == "Andrew" + + entry.user = "August" + user_pane = columns[1][0][0].object() + assert user_pane.object == "August" + + entry.show_user = False + user_pane = columns[1][0][0].object() + assert not user_pane.visible + + def test_update_value(self): + entry = ChatEntry(value="Test") + columns = entry._composite.objects + value_pane = columns[1][1][0].object() + assert isinstance(value_pane, Markdown) + assert value_pane.object == "Test" + + entry.value = TextInput(value="Also testing...") + value_pane = columns[1][1][0].object() + assert isinstance(value_pane, TextInput) + assert value_pane.value == "Also testing..." + + entry.value = _FileInputMessage( + contents=b"I am a file", file_name="test.txt", mime_type="text/plain" + ) + value_pane = columns[1][1][0].object() + assert isinstance(value_pane, Markdown) + assert value_pane.object == "I am a file" + + def test_update_timestamp(self): + entry = ChatEntry() + columns = entry._composite.objects + timestamp_pane = columns[1][2].object() + assert isinstance(timestamp_pane, HTML) + dt_str = datetime.datetime.utcnow().strftime("%H:%M") + assert timestamp_pane.object == dt_str + + special_dt = datetime.datetime(2023, 6, 24, 15) + entry.timestamp = special_dt + timestamp_pane = columns[1][2].object() + dt_str = special_dt.strftime("%H:%M") + assert timestamp_pane.object == dt_str + + mm_dd_yyyy = "%b %d, %Y" + entry.timestamp_format = mm_dd_yyyy + timestamp_pane = columns[1][2].object() + dt_str = special_dt.strftime(mm_dd_yyyy) + assert timestamp_pane.object == dt_str + + entry.show_timestamp = False + timestamp_pane = columns[1][2].object() + assert not timestamp_pane.visible + + def test_does_not_turn_widget_into_str(self): + button = Button() + entry = ChatEntry(value=button) + assert entry.value == button + + @mpl_available + def test_can_display_any_python_object_that_panel_can_display(self): + # For example matplotlib figures + ChatEntry(value=mpl_figure()) + + # For example async functions + async def async_func(): + return "hello" + + ChatEntry(value=async_func) + + # For example async generators + async def async_generator(): + yield "hello" + yield "world" + + ChatEntry(value=async_generator) + + def test_can_use_pn_param_without_raising_exceptions(self): + entry = ChatEntry() + Param(entry) + + def test_bind_reactions(self): + def callback(reactions): + entry.value = " ".join(reactions) + + entry = ChatEntry(value="Hello") + bind(callback, entry.param.reactions, watch=True) + entry.reactions = ["favorite"] + assert entry.value == "favorite" + + def test_show_reaction_icons(self): + entry = ChatEntry() + assert entry.reaction_icons.visible + entry.show_reaction_icons = False + assert not entry.reaction_icons.visible + + def test_default_avatars(self): + assert isinstance(ChatEntry.default_avatars, dict) + assert ChatEntry(user="Assistant").avatar == ChatEntry(user="assistant").avatar + assert ChatEntry(value="Hello", user="NoDefaultUserAvatar").avatar == "" + + def test_default_avatars_depends_on_user(self): + ChatEntry.default_avatars["test1"] = "1" + ChatEntry.default_avatars["test2"] = "2" + + entry = ChatEntry(value="Hello", user="test1") + assert entry.avatar == "1" + + entry.user = "test2" + assert entry.avatar == "2" + + def test_default_avatars_can_be_updated_but_the_original_stays(self): + assert ChatEntry(user="Assistant").avatar == "🤖" + ChatEntry.default_avatars["assistant"] = "👨" + assert ChatEntry(user="Assistant").avatar == "👨" + + assert ChatEntry(user="System").avatar == "⚙️" + + def test_chat_copy_icon(self): + entry = ChatEntry(value="testing") + assert entry.chat_copy_icon.visible + assert entry.chat_copy_icon.value == "testing" + + @pytest.mark.parametrize("widget", [TextInput, TextAreaInput]) + def test_chat_copy_icon_text_widget(self, widget): + entry = ChatEntry(value=widget(value="testing")) + assert entry.chat_copy_icon.visible + assert entry.chat_copy_icon.value == "testing" + + def test_chat_copy_icon_disabled(self): + entry = ChatEntry(value="testing", show_copy_icon=False) + assert not entry.chat_copy_icon.visible + assert not entry.chat_copy_icon.value + + @pytest.mark.parametrize("component", [Column, FileInput]) + def test_chat_copy_icon_not_string(self, component): + entry = ChatEntry(value=component()) + assert not entry.chat_copy_icon.visible + assert not entry.chat_copy_icon.value diff --git a/panel/tests/chat/test_base.py b/panel/tests/chat/test_feed.py similarity index 57% rename from panel/tests/chat/test_base.py rename to panel/tests/chat/test_feed.py index 1e07c3b37a..954d10bc9f 100644 --- a/panel/tests/chat/test_base.py +++ b/panel/tests/chat/test_feed.py @@ -1,23 +1,17 @@ import asyncio -import datetime import time from unittest.mock import MagicMock import pytest -from panel import Param, bind -from panel.chat.base import ( - ChatEntry, ChatFeed, ChatInterface, _FileInputMessage, -) -from panel.chat.icon import ChatReactionIcons -from panel.layout import Column, Row, Tabs -from panel.pane.image import SVG, Image -from panel.pane.markup import HTML, Markdown -from panel.tests.util import mpl_available, mpl_figure -from panel.widgets.button import Button +from panel.chat.entry import ChatEntry +from panel.chat.feed import ChatFeed +from panel.layout import Column, Row +from panel.pane.image import Image +from panel.pane.markup import HTML from panel.widgets.indicators import LinearGauge -from panel.widgets.input import FileInput, TextAreaInput, TextInput +from panel.widgets.input import TextAreaInput, TextInput LAYOUT_PARAMETERS = { "sizing_mode": "stretch_height", @@ -28,227 +22,6 @@ } -class TestChatEntry: - def test_layout(self): - entry = ChatEntry(value="ABC") - columns = entry._composite.objects - assert len(columns) == 2 - - avatar_pane = columns[0][0].object() - assert isinstance(avatar_pane, HTML) - assert avatar_pane.object == "🧑" - - row = columns[1][0] - user_pane = row[0].object() - assert isinstance(user_pane, HTML) - assert user_pane.object == "User" - - center_row = columns[1][1] - assert isinstance(center_row, Row) - - value_pane = center_row[0].object() - assert isinstance(value_pane, Markdown) - assert value_pane.object == "ABC" - - icons = center_row[1] - assert isinstance(icons, ChatReactionIcons) - - timestamp_pane = columns[1][2].object() - assert isinstance(timestamp_pane, HTML) - - def test_reactions_link(self): - # on init - entry = ChatEntry(reactions=["favorite"]) - assert entry.reaction_icons.value == ["favorite"] - - # on change in entry - entry.reactions = [] - assert entry.reaction_icons.value == [] - - # on change in reaction_icons - entry.reactions = ["favorite"] - assert entry.reaction_icons.value == ["favorite"] - - def test_reaction_icons_input_dict(self): - entry = ChatEntry(reaction_icons={"favorite": "heart"}) - assert isinstance(entry.reaction_icons, ChatReactionIcons) - assert entry.reaction_icons.options == {"favorite": "heart"} - - def test_update_avatar(self): - entry = ChatEntry(avatar="A") - columns = entry._composite.objects - avatar_pane = columns[0][0].object() - assert isinstance(avatar_pane, HTML) - assert avatar_pane.object == "A" - - entry.avatar = "B" - avatar_pane = columns[0][0].object() - assert avatar_pane.object == "B" - - entry.avatar = "❤️" - avatar_pane = columns[0][0].object() - assert avatar_pane.object == "❤️" - - entry.avatar = "https://assets.holoviz.org/panel/samples/jpg_sample.jpg" - avatar_pane = columns[0][0].object() - assert isinstance(avatar_pane, Image) - assert ( - avatar_pane.object - == "https://assets.holoviz.org/panel/samples/jpg_sample.jpg" - ) - - entry.show_avatar = False - avatar_pane = columns[0][0].object() - assert not avatar_pane.visible - - entry.avatar = SVG("https://tabler-icons.io/static/tabler-icons/icons/user.svg") - avatar_pane = columns[0][0].object() - assert isinstance(avatar_pane, SVG) - - def test_update_user(self): - entry = ChatEntry(user="Andrew") - columns = entry._composite.objects - user_pane = columns[1][0][0].object() - assert isinstance(user_pane, HTML) - assert user_pane.object == "Andrew" - - entry.user = "August" - user_pane = columns[1][0][0].object() - assert user_pane.object == "August" - - entry.show_user = False - user_pane = columns[1][0][0].object() - assert not user_pane.visible - - def test_update_value(self): - entry = ChatEntry(value="Test") - columns = entry._composite.objects - value_pane = columns[1][1][0].object() - assert isinstance(value_pane, Markdown) - assert value_pane.object == "Test" - - entry.value = TextInput(value="Also testing...") - value_pane = columns[1][1][0].object() - assert isinstance(value_pane, TextInput) - assert value_pane.value == "Also testing..." - - entry.value = _FileInputMessage( - contents=b"I am a file", file_name="test.txt", mime_type="text/plain" - ) - value_pane = columns[1][1][0].object() - assert isinstance(value_pane, Markdown) - assert value_pane.object == "I am a file" - - def test_update_timestamp(self): - entry = ChatEntry() - columns = entry._composite.objects - timestamp_pane = columns[1][2].object() - assert isinstance(timestamp_pane, HTML) - dt_str = datetime.datetime.utcnow().strftime("%H:%M") - assert timestamp_pane.object == dt_str - - special_dt = datetime.datetime(2023, 6, 24, 15) - entry.timestamp = special_dt - timestamp_pane = columns[1][2].object() - dt_str = special_dt.strftime("%H:%M") - assert timestamp_pane.object == dt_str - - mm_dd_yyyy = "%b %d, %Y" - entry.timestamp_format = mm_dd_yyyy - timestamp_pane = columns[1][2].object() - dt_str = special_dt.strftime(mm_dd_yyyy) - assert timestamp_pane.object == dt_str - - entry.show_timestamp = False - timestamp_pane = columns[1][2].object() - assert not timestamp_pane.visible - - def test_does_not_turn_widget_into_str(self): - button = Button() - entry = ChatEntry(value=button) - assert entry.value == button - - @mpl_available - def test_can_display_any_python_object_that_panel_can_display(self): - # For example matplotlib figures - ChatEntry(value=mpl_figure()) - - # For example async functions - async def async_func(): - return "hello" - - ChatEntry(value=async_func) - - # For example async generators - async def async_generator(): - yield "hello" - yield "world" - - ChatEntry(value=async_generator) - - def test_can_use_pn_param_without_raising_exceptions(self): - entry = ChatEntry() - Param(entry) - - def test_bind_reactions(self): - def callback(reactions): - entry.value = " ".join(reactions) - - entry = ChatEntry(value="Hello") - bind(callback, entry.param.reactions, watch=True) - entry.reactions = ["favorite"] - assert entry.value == "favorite" - - def test_show_reaction_icons(self): - entry = ChatEntry() - assert entry.reaction_icons.visible - entry.show_reaction_icons = False - assert not entry.reaction_icons.visible - - def test_default_avatars(self): - assert isinstance(ChatEntry.default_avatars, dict) - assert ChatEntry(user="Assistant").avatar == ChatEntry(user="assistant").avatar - assert ChatEntry(value="Hello", user="NoDefaultUserAvatar").avatar == "" - - def test_default_avatars_depends_on_user(self): - ChatEntry.default_avatars["test1"] = "1" - ChatEntry.default_avatars["test2"] = "2" - - entry = ChatEntry(value="Hello", user="test1") - assert entry.avatar == "1" - - entry.user = "test2" - assert entry.avatar == "2" - - def test_default_avatars_can_be_updated_but_the_original_stays(self): - assert ChatEntry(user="Assistant").avatar == "🤖" - ChatEntry.default_avatars["assistant"] = "👨" - assert ChatEntry(user="Assistant").avatar == "👨" - - assert ChatEntry(user="System").avatar == "⚙️" - - def test_chat_copy_icon(self): - entry = ChatEntry(value="testing") - assert entry.chat_copy_icon.visible - assert entry.chat_copy_icon.value == "testing" - - @pytest.mark.parametrize("widget", [TextInput, TextAreaInput]) - def test_chat_copy_icon_text_widget(self, widget): - entry = ChatEntry(value=widget(value="testing")) - assert entry.chat_copy_icon.visible - assert entry.chat_copy_icon.value == "testing" - - def test_chat_copy_icon_disabled(self): - entry = ChatEntry(value="testing", show_copy_icon=False) - assert not entry.chat_copy_icon.visible - assert not entry.chat_copy_icon.value - - @pytest.mark.parametrize("component", [Column, FileInput]) - def test_chat_copy_icon_not_string(self, component): - entry = ChatEntry(value=component()) - assert not entry.chat_copy_icon.visible - assert not entry.chat_copy_icon.value - class TestChatFeed: @pytest.fixture def chat_feed(self): @@ -603,6 +376,7 @@ def callback(contents, user, instance): assert len(chat_feed.value) == 1 assert chat_feed.value[0].value == "Mutated" + class TestChatFeedCallback: @pytest.fixture def chat_feed(self) -> ChatFeed: @@ -872,200 +646,3 @@ def callback(msg, user, instance): with pytest.raises(ZeroDivisionError, match="division by zero"): chat_feed.send("Message", respond=True) assert len(chat_feed.value) == 1 - - -class TestChatInterfaceWidgetsSizingMode: - def test_none(self): - chat_interface = ChatInterface() - assert chat_interface.sizing_mode is None - assert chat_interface._chat_log.sizing_mode is None - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - def test_fixed(self): - chat_interface = ChatInterface(sizing_mode="fixed") - assert chat_interface.sizing_mode == "fixed" - assert chat_interface._chat_log.sizing_mode == "fixed" - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - def test_stretch_both(self): - chat_interface = ChatInterface(sizing_mode="stretch_both") - assert chat_interface.sizing_mode == "stretch_both" - assert chat_interface._chat_log.sizing_mode == "stretch_both" - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - def test_stretch_width(self): - chat_interface = ChatInterface(sizing_mode="stretch_width") - assert chat_interface.sizing_mode == "stretch_width" - assert chat_interface._chat_log.sizing_mode == "stretch_width" - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - def test_stretch_height(self): - chat_interface = ChatInterface(sizing_mode="stretch_height") - assert chat_interface.sizing_mode == "stretch_height" - assert chat_interface._chat_log.sizing_mode == "stretch_height" - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - def test_scale_both(self): - chat_interface = ChatInterface(sizing_mode="scale_both") - assert chat_interface.sizing_mode == "scale_both" - assert chat_interface._chat_log.sizing_mode == "scale_both" - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - def test_scale_width(self): - chat_interface = ChatInterface(sizing_mode="scale_width") - assert chat_interface.sizing_mode == "scale_width" - assert chat_interface._chat_log.sizing_mode == "scale_width" - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - def test_scale_height(self): - chat_interface = ChatInterface(sizing_mode="scale_height") - assert chat_interface.sizing_mode == "scale_height" - assert chat_interface._chat_log.sizing_mode == "scale_height" - assert chat_interface._input_layout.sizing_mode == "stretch_width" - assert chat_interface._input_layout[0].sizing_mode == "stretch_width" - - -class TestChatInterface: - @pytest.fixture - def chat_interface(self): - return ChatInterface() - - def test_init(self, chat_interface): - assert len(chat_interface._button_data) == 4 - assert len(chat_interface._widgets) == 1 - assert isinstance(chat_interface._input_layout, Row) - assert isinstance(chat_interface._widgets["TextInput"], TextInput) - - assert chat_interface.active == -1 - - # Buttons added to input layout - inputs = chat_interface._input_layout - for index, button_data in enumerate(chat_interface._button_data.values()): - widget = inputs[index + 1] - assert isinstance(widget, Button) - assert widget.name == button_data.name.title() - - def test_init_custom_widgets(self): - widgets = [TextInput(name="Text"), FileInput()] - chat_interface = ChatInterface(widgets=widgets) - assert len(chat_interface._widgets) == 2 - assert isinstance(chat_interface._input_layout, Tabs) - assert isinstance(chat_interface._widgets["Text"], TextInput) - assert isinstance(chat_interface._widgets["FileInput"], FileInput) - assert chat_interface.active == 0 - - def test_active_in_constructor(self): - widgets = [TextInput(name="Text"), FileInput()] - chat_interface = ChatInterface(widgets=widgets, active=1) - assert chat_interface.active == 1 - - def test_file_input_only(self): - ChatInterface(widgets=[FileInput(name="CSV File", accept=".csv")]) - - def test_active_widget(self, chat_interface): - active_widget = chat_interface.active_widget - assert isinstance(active_widget, TextInput) - - def test_active(self): - widget = TextInput(name="input") - chat_interface = ChatInterface(widgets=[widget]) - assert chat_interface.active == -1 - - def test_active_multiple_widgets(self, chat_interface): - widget1 = TextInput(name="input1") - widget2 = TextInput(name="input2") - chat_interface.widgets = [widget1, widget2] - assert chat_interface.active == 0 - - chat_interface.active = 1 - assert chat_interface.active == 1 - assert isinstance(chat_interface.active_widget, TextInput) - - def test_click_send(self, chat_interface: ChatInterface): - chat_interface.widgets = [TextAreaInput()] - chat_interface.active_widget.value = "Message" - assert len(chat_interface.value) == 1 - assert chat_interface.value[0].value == "Message" - - def test_click_undo(self, chat_interface): - chat_interface.user = "User" - chat_interface.send("Message 1") - chat_interface.send("Message 2") - chat_interface.send("Message 3", user="Assistant") - expected = chat_interface.value[-2:].copy() - chat_interface._click_undo(None) - assert len(chat_interface.value) == 1 - assert chat_interface.value[0].value == "Message 1" - assert chat_interface._button_data["undo"].objects == expected - - # revert - chat_interface._click_undo(None) - assert len(chat_interface.value) == 3 - assert chat_interface.value[0].value == "Message 1" - assert chat_interface.value[1].value == "Message 2" - assert chat_interface.value[2].value == "Message 3" - - def test_click_clear(self, chat_interface): - chat_interface.send("Message 1") - chat_interface.send("Message 2") - chat_interface.send("Message 3") - expected = chat_interface.value.copy() - chat_interface._click_clear(None) - assert len(chat_interface.value) == 0 - assert chat_interface._button_data["clear"].objects == expected - - def test_click_rerun(self, chat_interface): - self.count = 0 - - def callback(contents, user, instance): - self.count += 1 - return self.count - - chat_interface.callback = callback - chat_interface.send("Message 1") - assert chat_interface.value[1].value == 1 - chat_interface._click_rerun(None) - assert chat_interface.value[1].value == 2 - - def test_click_rerun_null(self, chat_interface): - chat_interface._click_rerun(None) - assert len(chat_interface.value) == 0 - - def test_replace_widgets(self, chat_interface): - assert isinstance(chat_interface._input_layout, Row) - - chat_interface.widgets = [TextAreaInput(), FileInput()] - assert len(chat_interface._widgets) == 2 - assert isinstance(chat_interface._input_layout, Tabs) - assert isinstance(chat_interface._widgets["TextAreaInput"], TextAreaInput) - assert isinstance(chat_interface._widgets["FileInput"], FileInput) - - def test_reset_on_send(self, chat_interface): - chat_interface.active_widget.value = "Hello" - chat_interface.reset_on_send = True - chat_interface._click_send(None) - assert chat_interface.active_widget.value == "" - - def test_reset_on_send_text_area(self, chat_interface): - chat_interface.widgets = TextAreaInput() - chat_interface.active_widget.value = "Hello" - chat_interface.reset_on_send = False - chat_interface._click_send(None) - assert chat_interface.active_widget.value == "Hello" - - def test_widgets_supports_list_and_widget(self, chat_interface): - chat_interface.widgets = TextAreaInput() - chat_interface.widgets = [TextAreaInput(), FileInput] - - def test_show_button_name_width(self, chat_interface): - assert chat_interface.show_button_name - assert chat_interface.width is None - chat_interface.width = 200 - assert not chat_interface.show_button_name diff --git a/panel/tests/chat/test_icon.py b/panel/tests/chat/test_icon.py index 2406476773..3c06d39d04 100644 --- a/panel/tests/chat/test_icon.py +++ b/panel/tests/chat/test_icon.py @@ -1,6 +1,3 @@ - - - from panel.chat.icon import ChatReactionIcons from panel.pane.image import SVG diff --git a/panel/tests/chat/test_interface.py b/panel/tests/chat/test_interface.py new file mode 100644 index 0000000000..01059e2688 --- /dev/null +++ b/panel/tests/chat/test_interface.py @@ -0,0 +1,205 @@ + + +import pytest + +from panel.chat.interface import ChatInterface +from panel.layout import Row, Tabs +from panel.widgets.button import Button +from panel.widgets.input import FileInput, TextAreaInput, TextInput + + +class TestChatInterface: + @pytest.fixture + def chat_interface(self): + return ChatInterface() + + def test_init(self, chat_interface): + assert len(chat_interface._button_data) == 4 + assert len(chat_interface._widgets) == 1 + assert isinstance(chat_interface._input_layout, Row) + assert isinstance(chat_interface._widgets["TextInput"], TextInput) + + assert chat_interface.active == -1 + + # Buttons added to input layout + inputs = chat_interface._input_layout + for index, button_data in enumerate(chat_interface._button_data.values()): + widget = inputs[index + 1] + assert isinstance(widget, Button) + assert widget.name == button_data.name.title() + + def test_init_custom_widgets(self): + widgets = [TextInput(name="Text"), FileInput()] + chat_interface = ChatInterface(widgets=widgets) + assert len(chat_interface._widgets) == 2 + assert isinstance(chat_interface._input_layout, Tabs) + assert isinstance(chat_interface._widgets["Text"], TextInput) + assert isinstance(chat_interface._widgets["FileInput"], FileInput) + assert chat_interface.active == 0 + + def test_active_in_constructor(self): + widgets = [TextInput(name="Text"), FileInput()] + chat_interface = ChatInterface(widgets=widgets, active=1) + assert chat_interface.active == 1 + + def test_file_input_only(self): + ChatInterface(widgets=[FileInput(name="CSV File", accept=".csv")]) + + def test_active_widget(self, chat_interface): + active_widget = chat_interface.active_widget + assert isinstance(active_widget, TextInput) + + def test_active(self): + widget = TextInput(name="input") + chat_interface = ChatInterface(widgets=[widget]) + assert chat_interface.active == -1 + + def test_active_multiple_widgets(self, chat_interface): + widget1 = TextInput(name="input1") + widget2 = TextInput(name="input2") + chat_interface.widgets = [widget1, widget2] + assert chat_interface.active == 0 + + chat_interface.active = 1 + assert chat_interface.active == 1 + assert isinstance(chat_interface.active_widget, TextInput) + + def test_click_send(self, chat_interface: ChatInterface): + chat_interface.widgets = [TextAreaInput()] + chat_interface.active_widget.value = "Message" + assert len(chat_interface.value) == 1 + assert chat_interface.value[0].value == "Message" + + def test_click_undo(self, chat_interface): + chat_interface.user = "User" + chat_interface.send("Message 1") + chat_interface.send("Message 2") + chat_interface.send("Message 3", user="Assistant") + expected = chat_interface.value[-2:].copy() + chat_interface._click_undo(None) + assert len(chat_interface.value) == 1 + assert chat_interface.value[0].value == "Message 1" + assert chat_interface._button_data["undo"].objects == expected + + # revert + chat_interface._click_undo(None) + assert len(chat_interface.value) == 3 + assert chat_interface.value[0].value == "Message 1" + assert chat_interface.value[1].value == "Message 2" + assert chat_interface.value[2].value == "Message 3" + + def test_click_clear(self, chat_interface): + chat_interface.send("Message 1") + chat_interface.send("Message 2") + chat_interface.send("Message 3") + expected = chat_interface.value.copy() + chat_interface._click_clear(None) + assert len(chat_interface.value) == 0 + assert chat_interface._button_data["clear"].objects == expected + + def test_click_rerun(self, chat_interface): + self.count = 0 + + def callback(contents, user, instance): + self.count += 1 + return self.count + + chat_interface.callback = callback + chat_interface.send("Message 1") + assert chat_interface.value[1].value == 1 + chat_interface._click_rerun(None) + assert chat_interface.value[1].value == 2 + + def test_click_rerun_null(self, chat_interface): + chat_interface._click_rerun(None) + assert len(chat_interface.value) == 0 + + def test_replace_widgets(self, chat_interface): + assert isinstance(chat_interface._input_layout, Row) + + chat_interface.widgets = [TextAreaInput(), FileInput()] + assert len(chat_interface._widgets) == 2 + assert isinstance(chat_interface._input_layout, Tabs) + assert isinstance(chat_interface._widgets["TextAreaInput"], TextAreaInput) + assert isinstance(chat_interface._widgets["FileInput"], FileInput) + + def test_reset_on_send(self, chat_interface): + chat_interface.active_widget.value = "Hello" + chat_interface.reset_on_send = True + chat_interface._click_send(None) + assert chat_interface.active_widget.value == "" + + def test_reset_on_send_text_area(self, chat_interface): + chat_interface.widgets = TextAreaInput() + chat_interface.active_widget.value = "Hello" + chat_interface.reset_on_send = False + chat_interface._click_send(None) + assert chat_interface.active_widget.value == "Hello" + + def test_widgets_supports_list_and_widget(self, chat_interface): + chat_interface.widgets = TextAreaInput() + chat_interface.widgets = [TextAreaInput(), FileInput] + + def test_show_button_name_width(self, chat_interface): + assert chat_interface.show_button_name + assert chat_interface.width is None + chat_interface.width = 200 + assert not chat_interface.show_button_name + + +class TestChatInterfaceWidgetsSizingMode: + def test_none(self): + chat_interface = ChatInterface() + assert chat_interface.sizing_mode is None + assert chat_interface._chat_log.sizing_mode is None + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" + + def test_fixed(self): + chat_interface = ChatInterface(sizing_mode="fixed") + assert chat_interface.sizing_mode == "fixed" + assert chat_interface._chat_log.sizing_mode == "fixed" + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" + + def test_stretch_both(self): + chat_interface = ChatInterface(sizing_mode="stretch_both") + assert chat_interface.sizing_mode == "stretch_both" + assert chat_interface._chat_log.sizing_mode == "stretch_both" + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" + + def test_stretch_width(self): + chat_interface = ChatInterface(sizing_mode="stretch_width") + assert chat_interface.sizing_mode == "stretch_width" + assert chat_interface._chat_log.sizing_mode == "stretch_width" + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" + + def test_stretch_height(self): + chat_interface = ChatInterface(sizing_mode="stretch_height") + assert chat_interface.sizing_mode == "stretch_height" + assert chat_interface._chat_log.sizing_mode == "stretch_height" + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" + + def test_scale_both(self): + chat_interface = ChatInterface(sizing_mode="scale_both") + assert chat_interface.sizing_mode == "scale_both" + assert chat_interface._chat_log.sizing_mode == "scale_both" + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" + + def test_scale_width(self): + chat_interface = ChatInterface(sizing_mode="scale_width") + assert chat_interface.sizing_mode == "scale_width" + assert chat_interface._chat_log.sizing_mode == "scale_width" + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" + + def test_scale_height(self): + chat_interface = ChatInterface(sizing_mode="scale_height") + assert chat_interface.sizing_mode == "scale_height" + assert chat_interface._chat_log.sizing_mode == "scale_height" + assert chat_interface._input_layout.sizing_mode == "stretch_width" + assert chat_interface._input_layout[0].sizing_mode == "stretch_width" diff --git a/panel/tests/widgets/test_chatbox.py b/panel/tests/widgets/test_chatbox.py index d1096563de..0dacdb706f 100644 --- a/panel/tests/widgets/test_chatbox.py +++ b/panel/tests/widgets/test_chatbox.py @@ -1,10 +1,10 @@ import pytest -from panel.chatbox import ChatBox, ChatRow from panel.depends import bind from panel.layout import Column from panel.pane import JPG from panel.widgets import FileInput, TextInput +from panel.widgets.chatbox import ChatBox, ChatRow JPG_FILE = "https://assets.holoviz.org/panel/samples/jpg_sample.jpg" From 6799912677895f77f84f218dcc2b7dbcf437f5b4 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 13 Oct 2023 11:14:41 -0700 Subject: [PATCH 10/20] FIx imports --- panel/chat/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/panel/chat/__init__.py b/panel/chat/__init__.py index 4b3f4fbd0c..11ed244c91 100644 --- a/panel/chat/__init__.py +++ b/panel/chat/__init__.py @@ -28,9 +28,10 @@ For more detail see the Reference Gallery guide. https://panel.holoviz.org/reference/chat/ChatInterface.html """ -from .base import ( # noqa - ChatEntry, ChatFeed, ChatInterface, ChatReactionIcons, -) +from .entry import ChatEntry # noqa +from .feed import ChatFeed # noqa +from .icon import ChatReactionIcons # noqa +from .interface import ChatInterface # noqa from .langchain import PanelCallbackHandler # noqa __all__ = ( From 7a180422c1962a943c85021f8ac4a867c119f38a Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Sun, 15 Oct 2023 06:09:03 -0700 Subject: [PATCH 11/20] Fill the copy icon temporarily when clicked (#5636) --- panel/chat/icon.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/panel/chat/icon.py b/panel/chat/icon.py index b5194893fe..d978882b2a 100644 --- a/panel/chat/icon.py +++ b/panel/chat/icon.py @@ -186,6 +186,8 @@ def _update_icons(self): class ChatCopyIcon(ReactiveHTML): value = param.String(default=None, doc="The text to copy to the clipboard.") + fill = param.String(default="none", doc="The fill color of the icon.") + _template = """
- @@ -205,6 +207,10 @@ class ChatCopyIcon(ReactiveHTML):
""" - _scripts = {"copy_to_clipboard": "navigator.clipboard.writeText(`${data.value}`)"} + _scripts = {"copy_to_clipboard": """ + navigator.clipboard.writeText(`${data.value}`); + data.fill = "currentColor"; + setTimeout(() => data.fill = "none", 50); + """} _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_copy_icon.css"] From ce5145810dac9ea14060ff712a773bdedd86dce3 Mon Sep 17 00:00:00 2001 From: Andrew <15331990+ahuang11@users.noreply.github.com> Date: Sun, 15 Oct 2023 10:06:18 -0700 Subject: [PATCH 12/20] Improve Langchain PanelCallbackHandler documentation and operations (#5634) --- .../reference/chat/PanelCallbackHandler.ipynb | 160 ++++++++++++++++++ panel/chat/langchain.py | 84 ++++++--- panel/tests/chat/test_langchain.py | 35 ++++ 3 files changed, 258 insertions(+), 21 deletions(-) create mode 100644 examples/reference/chat/PanelCallbackHandler.ipynb create mode 100644 panel/tests/chat/test_langchain.py diff --git a/examples/reference/chat/PanelCallbackHandler.ipynb b/examples/reference/chat/PanelCallbackHandler.ipynb new file mode 100644 index 0000000000..aaedd0b894 --- /dev/null +++ b/examples/reference/chat/PanelCallbackHandler.ipynb @@ -0,0 +1,160 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The Langchain `PanelCallbackHandler` itself is not a widget or pane, but is useful for rendering and streaming output from Langchain Tools, Agents, and Chains as `ChatEntry` objects. It inherits from Langchain's [BaseCallbackHandler](https://python.langchain.com/docs/modules/callbacks/).\n", + "\n", + "#### Parameters:\n", + "\n", + "##### Core\n", + "\n", + "* **`instance`** (ChatFeed | ChatInterface): The ChatFeed or ChatInterface instance.\n", + "* **`user`** (str): Name of the user who sent the message.\n", + "* **`avatar`** (str | BinaryIO): The avatar to use for the user. Can be a single character text, an emoji, or anything supported by `pn.pane.Image`. If not set, uses the first character of the name.\n", + "\n", + "___" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Basics\n", + "\n", + "To get started:\n", + "\n", + "1. Pass the instance of a `ChatFeed` or `ChatInterface` to `PanelCallbackHandler`.\n", + "2. Pass the `callback_handler` as a list into `callbacks` when constructing Langchain objects.\n", + "\n", + "\n", + "```python\n", + "import panel as pn\n", + "from langchain.llms import OpenAI\n", + "\n", + "pn.extension()\n", + "\n", + "\n", + "def callback(contents, user, instance):\n", + " llm.predict(contents)\n", + "\n", + "\n", + "instance = pn.chat.ChatInterface(callback=callback)\n", + "callback_handler = pn.chat.PanelCallbackHandler(instance)\n", + "\n", + "llm = OpenAI(temperature=0, callbacks=[callback_handler])\n", + "\n", + "instance.servable()\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Async\n", + "\n", + "`async` can also be used:\n", + "\n", + "1. Prefix the function with `async`.\n", + "2. Replace the `predict` call with `apredict`.\n", + "3. Prefix the call with `await`.\n", + "\n", + "```python\n", + "import panel as pn\n", + "from langchain.llms import OpenAI\n", + "\n", + "pn.extension()\n", + "\n", + "\n", + "async def callback(contents, user, instance):\n", + " await llm.apredict(contents)\n", + "\n", + "\n", + "instance = pn.chat.ChatInterface(callback=callback)\n", + "callback_handler = pn.chat.PanelCallbackHandler(instance)\n", + "\n", + "llm = OpenAI(temperature=0, callbacks=[callback_handler])\n", + "\n", + "instance.servable()\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Streaming\n", + "\n", + "To stream tokens from the LLM, simply set `streaming=True` when constructing the LLM.\n", + "\n", + "Note, `async` is not required to stream, but it is more efficient.\n", + "\n", + "```python\n", + "import panel as pn\n", + "from langchain.llms import OpenAI\n", + "\n", + "pn.extension()\n", + "\n", + "\n", + "async def callback(contents, user, instance):\n", + " await llm.apredict(contents)\n", + "\n", + "\n", + "instance = pn.chat.ChatInterface(callback=callback)\n", + "callback_handler = pn.chat.PanelCallbackHandler(instance)\n", + "\n", + "llm = OpenAI(temperature=0, callbacks=[callback_handler], streaming=True)\n", + "\n", + "instance.servable()\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Agents & Tools\n", + "\n", + "To differentiate between agents', tools', and other LLM output (i.e. automatically use corresponding avatars and names), be sure to also provide `callback_handler` to their constructors.\n", + "\n", + "Again, `async` is not required, but more efficient.\n", + "\n", + "```python\n", + "import panel as pn\n", + "from langchain.agents import AgentType, load_tools, initialize_agent\n", + "from langchain.llms import OpenAI\n", + "\n", + "pn.extension()\n", + "\n", + "\n", + "async def callback(contents, *args):\n", + " await agent.arun(contents)\n", + "\n", + "\n", + "instance = pn.chat.ChatInterface(callback=callback)\n", + "callback_handler = pn.chat.PanelCallbackHandler(instance)\n", + "llm = OpenAI(temperature=0, callbacks=[callback_handler], streaming=True)\n", + "tools = load_tools([\"serpapi\", \"llm-math\"], llm=llm, callbacks=[callback_handler])\n", + "agent = initialize_agent(\n", + " tools,\n", + " llm,\n", + " agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,\n", + " callbacks=[callback_handler],\n", + ")\n", + "\n", + "instance.servable()\n", + "```" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/panel/chat/langchain.py b/panel/chat/langchain.py index 2588c6cf31..109299d762 100644 --- a/panel/chat/langchain.py +++ b/panel/chat/langchain.py @@ -1,3 +1,7 @@ +"""The langchain module integrates Langchain support with Panel.""" + +from __future__ import annotations + from typing import Any, Dict, Union try: @@ -9,13 +13,28 @@ AgentFinish = None LLMResult = None -from panel.chat import ChatInterface +from panel.chat import ChatFeed, ChatInterface class PanelCallbackHandler(BaseCallbackHandler): + """ + The Langchain `PanelCallbackHandler` itself is not a widget or pane, but is useful for rendering + and streaming output from Langchain Tools, Agents, and Chains as `ChatEntry` objects. + + Reference: https://panel.holoviz.org/reference/chat/PanelCallbackHandler.html + + :Example: + + >>> chat_interface = pn.widgets.ChatInterface(callback=callback, callback_user="Langchain") + >>> callback_handler = pn.widgets.langchain.PanelCallbackHandler(instance=chat_interface) + >>> llm = ChatOpenAI(streaming=True, callbacks=[callback_handler]) + >>> chain = ConversationChain(llm=llm) + + """ + def __init__( self, - chat_interface: ChatInterface, + instance: ChatFeed | ChatInterface, user: str = "LangChain", avatar: str = "🦜️", ): @@ -23,18 +42,31 @@ def __init__( raise ImportError( "LangChainCallbackHandler requires `langchain` to be installed." ) - self.chat_interface = chat_interface + self.instance = instance self._entry = None self._active_user = user self._active_avatar = avatar - self._disabled_state = self.chat_interface.disabled + self._disabled_state = self.instance.disabled + self._is_streaming = None - self._input_user = user + self._input_user = user # original user self._input_avatar = avatar + def _update_active(self, avatar: str, label: str): + """ + Prevent duplicate labels from being appended to the same user. + """ + # not a typo; Langchain passes a string :/ + if label == "None": + return + + if f"- {label}" not in self._active_user: + self._active_user = f"{self._active_user} - {label}" + def on_llm_start(self, serialized: Dict[str, Any], *args, **kwargs): model = kwargs.get("invocation_params", {}).get("model_name", "") - entries = self.chat_interface.value + self._is_streaming = serialized.get("kwargs", {}).get("streaming") + entries = self.instance.value if entries[-1].user != self._active_user: self._entry = None if self._active_user and model not in self._active_user: @@ -42,7 +74,7 @@ def on_llm_start(self, serialized: Dict[str, Any], *args, **kwargs): return super().on_llm_start(serialized, *args, **kwargs) def on_llm_new_token(self, token: str, **kwargs) -> None: - self._entry = self.chat_interface.stream( + self._entry = self.instance.stream( token, user=self._active_user, avatar=self._active_avatar, @@ -51,12 +83,25 @@ def on_llm_new_token(self, token: str, **kwargs) -> None: return super().on_llm_new_token(token, **kwargs) def on_llm_end(self, response: LLMResult, *args, **kwargs): + if not self._is_streaming: + # on_llm_new_token does not get called if not streaming + self._entry = self.instance.stream( + response.generations[0][0].text, + user=self._active_user, + avatar=self._active_avatar, + entry=self._entry, + ) + if self._active_user != self._input_user: + self._active_user = self._input_user + self._active_avatar = self._input_avatar + self._entry = None return super().on_llm_end(response, *args, **kwargs) def on_llm_error(self, error: Union[Exception, KeyboardInterrupt], *args, **kwargs): return super().on_llm_error(error, *args, **kwargs) def on_agent_action(self, action: AgentAction, *args, **kwargs: Any) -> Any: + self._update_active("🛠️", action.tool) return super().on_agent_action(action, *args, **kwargs) def on_agent_finish(self, finish: AgentFinish, *args, **kwargs: Any) -> Any: @@ -65,13 +110,10 @@ def on_agent_finish(self, finish: AgentFinish, *args, **kwargs: Any) -> Any: def on_tool_start( self, serialized: Dict[str, Any], input_str: str, *args, **kwargs ): - self._active_avatar = "🛠️" - self._active_user = f"{self._active_user} - {serialized['name']}" + self._update_active("🛠️", serialized["name"]) return super().on_tool_start(serialized, input_str, *args, **kwargs) def on_tool_end(self, output, *args, **kwargs): - self._active_user = self._input_user - self._active_avatar = self._input_avatar return super().on_tool_end(output, *args, **kwargs) def on_tool_error( @@ -82,23 +124,23 @@ def on_tool_error( def on_chain_start( self, serialized: Dict[str, Any], inputs: Dict[str, Any], *args, **kwargs ): - self._entry = None - self.chat_interface.disabled = True + self.instance.disabled = True return super().on_chain_start(serialized, inputs, *args, **kwargs) def on_chain_end(self, outputs: Dict[str, Any], *args, **kwargs): - self.chat_interface.disabled = self._disabled_state + self.instance.disabled = self._disabled_state return super().on_chain_end(outputs, *args, **kwargs) def on_retriever_error( - self, - error: Union[Exception, KeyboardInterrupt], - **kwargs: Any, + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any ) -> Any: """Run when Retriever errors.""" + return super().on_retriever_error(error, **kwargs) - def on_retriever_end( - self, - **kwargs: Any, - ) -> Any: + def on_retriever_end(self, **kwargs: Any) -> Any: """Run when Retriever ends running.""" + return super().on_retriever_end(**kwargs) + + def on_text(self, text: str, **kwargs: Any): + """Run when text is received.""" + return super().on_text(text, **kwargs) diff --git a/panel/tests/chat/test_langchain.py b/panel/tests/chat/test_langchain.py new file mode 100644 index 0000000000..fc2f506596 --- /dev/null +++ b/panel/tests/chat/test_langchain.py @@ -0,0 +1,35 @@ +import sys + +import pytest + +try: + from langchain.agents import AgentType, initialize_agent, load_tools + from langchain.llms.fake import FakeListLLM +except ImportError: + pytest.skip("langchain not installed", allow_module_level=True) + +from panel.chat import ChatFeed, ChatInterface +from panel.chat.langchain import PanelCallbackHandler + + +@pytest.mark.parametrize("streaming", [True, False]) +@pytest.mark.parametrize("instance_type", [ChatFeed, ChatInterface]) +def test_panel_callback_handler(streaming, instance_type): + async def callback(contents, user, instance): + await agent.arun(contents) + + instance = instance_type(callback=callback, callback_user="Langchain") + callback_handler = PanelCallbackHandler(instance) + tools = load_tools(["python_repl"]) + responses = ["Action: Python REPL\nAction Input: print(2 + 2)", "Final Answer: 4"] + llm_kwargs = dict(responses=responses, callbacks=[callback_handler], streaming=streaming) + if sys.version_info < (3, 9): + llm_kwargs.pop("streaming") + llm = FakeListLLM(**llm_kwargs) + agent = initialize_agent( + tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, callbacks=[callback_handler] + ) + instance.send("2 + 2") + assert len(instance.value) == 3 + assert instance.value[1].value == "Action: Python REPL\nAction Input: print(2 + 2)" + assert instance.value[2].value == "Final Answer: 4" From 28eea8a9075fb07093cdc1595f9aa336e30c7a25 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Sun, 15 Oct 2023 09:53:38 -0700 Subject: [PATCH 13/20] Udpate docstrings --- panel/chat/entry.py | 4 +++- panel/chat/feed.py | 4 +++- panel/chat/icon.py | 4 ++++ panel/chat/interface.py | 4 +++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/panel/chat/entry.py b/panel/chat/entry.py index f62b044f4b..7ac6e52546 100644 --- a/panel/chat/entry.py +++ b/panel/chat/entry.py @@ -1,5 +1,7 @@ -"""The entry module provides a low-level API for rendering chat messages. """ +The entry module provides a low-level API for rendering chat messages. +""" + from __future__ import annotations import datetime diff --git a/panel/chat/feed.py b/panel/chat/feed.py index b8bb86a44e..0e1cbe1d33 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -1,6 +1,8 @@ """ -A widget to display a list of `ChatEntry` objects. +The feed module provides a high-level API for interacting +with a list of `ChatEntry` objects through the backend methods. """ + from __future__ import annotations import asyncio diff --git a/panel/chat/icon.py b/panel/chat/icon.py index d978882b2a..63d91670a2 100644 --- a/panel/chat/icon.py +++ b/panel/chat/icon.py @@ -1,3 +1,7 @@ +""" +The icon module provides a low-level API for rendering chat related icons. +""" + from typing import ClassVar, List import param diff --git a/panel/chat/interface.py b/panel/chat/interface.py index 02a820174f..9706a47f72 100644 --- a/panel/chat/interface.py +++ b/panel/chat/interface.py @@ -1,5 +1,7 @@ """ -A widget to display a list of `ChatEntry` objects and interact with them. +The interface module provides an even higher-level API for interacting +with a list of `ChatEntry` objects compared to the `ChatFeed` +through a frontend input UI. """ from __future__ import annotations From 2c0ec8a697dfdbddf734afec206bd18a2944619c Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Sun, 15 Oct 2023 10:13:36 -0700 Subject: [PATCH 14/20] Add section --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index 224a143694..4bf25345a6 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -102,6 +102,7 @@ 'global', 'indicators', 'widgets', + 'chat', ], 'titles': { 'Vega': 'Altair & Vega', From a2fcb205f23e77f853b653644d30e89ea5213316 Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Mon, 16 Oct 2023 04:59:35 +0000 Subject: [PATCH 15/20] describe chat_message and chat_input --- .gitignore | 2 + doc/_static/images/panel_chat_entry.png | Bin 0 -> 41827 bytes doc/_static/images/panel_chat_input.png | Bin 0 -> 11278 bytes doc/_static/images/streamlit_chat_input.png | Bin 0 -> 8986 bytes doc/_static/images/streamlit_chat_message.png | Bin 0 -> 35015 bytes doc/how_to/streamlit_migration/chat.md | 145 ++++++++++++++++++ doc/how_to/streamlit_migration/index.md | 7 + 7 files changed, 154 insertions(+) create mode 100644 doc/_static/images/panel_chat_entry.png create mode 100644 doc/_static/images/panel_chat_input.png create mode 100644 doc/_static/images/streamlit_chat_input.png create mode 100644 doc/_static/images/streamlit_chat_message.png create mode 100644 doc/how_to/streamlit_migration/chat.md diff --git a/.gitignore b/.gitignore index 62a911e739..d090971534 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,5 @@ doc/reference/* .config/code-server/* .conda/* .jupyter/* +app_panel.py +app_streamlit.py diff --git a/doc/_static/images/panel_chat_entry.png b/doc/_static/images/panel_chat_entry.png new file mode 100644 index 0000000000000000000000000000000000000000..27f4c1e2377fddaff238f089ec61cbbed3b84019 GIT binary patch literal 41827 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 Rw!k$asVQs2Div+Q{s)gjKlA_q literal 0 HcmV?d00001 diff --git a/doc/_static/images/panel_chat_input.png b/doc/_static/images/panel_chat_input.png new file mode 100644 index 0000000000000000000000000000000000000000..661ae63bd5d32aec44e7085e24aef66f9b8a6225 GIT binary patch literal 11278 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 Z54C*o6k&2MAm^lyp|08eD(weP{tG-4C?Ws= literal 0 HcmV?d00001 diff --git a/doc/_static/images/streamlit_chat_input.png b/doc/_static/images/streamlit_chat_input.png new file mode 100644 index 0000000000000000000000000000000000000000..cce35d23f065d71c9240d59710468612b649a36e GIT binary patch literal 8986 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 OFgLNejlOmF@Baevt<5_C literal 0 HcmV?d00001 diff --git a/doc/_static/images/streamlit_chat_message.png b/doc/_static/images/streamlit_chat_message.png new file mode 100644 index 0000000000000000000000000000000000000000..8e4596bcc80f3d430154882c436fa9432099f50e GIT binary patch literal 35015 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

$leBY%Z0@R$J)}2 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%0D`UVX^Z{5* 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 Date: Mon, 16 Oct 2023 08:10:06 +0000 Subject: [PATCH 16/20] Chat Status --- doc/how_to/streamlit_migration/chat.md | 206 ++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 5 deletions(-) diff --git a/doc/how_to/streamlit_migration/chat.md b/doc/how_to/streamlit_migration/chat.md index cbff78e3eb..bc1e0e76df 100644 --- a/doc/how_to/streamlit_migration/chat.md +++ b/doc/how_to/streamlit_migration/chat.md @@ -4,14 +4,14 @@ Both Streamlit and Panel provides special components to help you build conversat | Streamlit | Panel | Description | | -------------------- | ------------------- | -------------------------------------- | -| `st.chat_message` | [`pn.chat.ChatEntry`](../../../examples/reference/chat/ChatEntry.ipynb) | Output a single chat message | -| `st_chat_input` | | Input a chat message | +| [`st.chat_message`](https://docs.streamlit.io/library/api-reference/chat/st.chat_message) | [`pn.chat.ChatEntry`](../../../examples/reference/chat/ChatEntry.ipynb) | Display a single chat message | +| [`st_chat_input`](https://docs.streamlit.io/library/api-reference/chat/st.chat_input) | | Input a chat message | | `st.status` | | Display output of long-running tasks in a container | -| | [`pn.chat.ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) | Output multiple of chat messages | +| | [`pn.chat.ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) | Display multiple chat messages | | | [`pn.chat.ChatInterface`](../../../examples/reference/chat/ChatInterface.ipynb) | High-level, easy to use chat interface | -| `langchain.callbacks.StreamlitCallbackHandler` | [`pn.chat.PanelCallbackHandler`](../../../examples/reference/chat/ChatInterface.ipynb) | Display the thoughts and actions of a LangChain agent | +| `langchain.callbacks.StreamlitCallbackHandler` | [`pn.chat.PanelCallbackHandler`](../../../examples/reference/chat/ChatInterface.ipynb) | Display the thoughts and actions of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | -The starting point for most Panel users would be the *high-level*, easy to use `ChatInterface` and `PanelCallbackHandler` components. Not the lower level `ChatEntry` and `ChatFeed` components. +The starting point for most Panel users is the *high-level* `ChatInterface`. Not the *low-level* `ChatEntry` and `ChatFeed` components. You can find specialized chat components and examples at [panel-chat-examples/](https://holoviz-topics.github.io/panel-chat-examples/). @@ -143,3 +143,199 @@ pn.Column(message, chat_input, margin=50).servable() ``` ![Panel ChatInput](../../_static/images/panel_chat_input.png) + +## Chat Status + +### Streamlit Chat Status + +```python +import time +import streamlit as st + +with st.status("Downloading data...", expanded=True): + st.write("Searching for data...") + time.sleep(1.5) + st.write("Downloading data...") + time.sleep(1.5) + st.write("Validating data...") + time.sleep(1.5) + +st.button("Run") +``` + +![Streamlit status](https://user-images.githubusercontent.com/42288570/275434382-992f352f-676a-4167-aad0-1fcc2745c130.gif) + +### Panel Chat Status + +Panel does not provide a dedicated *status* component because it is built into Panels high-level `ChatInterface`. Furthermore Panel provides a lot of other built in *indicators*. Check out the [Indicators Gallery](https://panel.holoviz.org/reference/index.html#indicators). + +Below we will show you how to build a custom `Status` indicator. + +```python +import time + +from contextlib import contextmanager + +import param + +import panel as pn + +from panel.widgets.indicators import LoadingSpinner + +pn.extension(design="material") + +COLORS = { + "running": "green", + "complete": "black", + "error": "red", + "next": "lightgray", +} + + +class Status(pn.viewable.Viewer): + value = param.Selector(default="complete", objects=["running", "complete", "error"]) + title = param.String() + + bgcolor = param.ObjectSelector( + default=LoadingSpinner.param.bgcolor.default, + objects=LoadingSpinner.param.bgcolor.objects, + ) + color = param.ObjectSelector( + default="success", objects=LoadingSpinner.param.color.objects + ) + collapsed = param.Boolean(default=True) + + steps = param.List(constant=True) + step = param.Parameter(constant=True) + + def __init__(self, title: str, **params): + params["title"] = title + params["steps"] = params.get("steps", []) + layout_params = { + key: value + for key, value in params.items() + if not key + in ["value", "title", "collapsed", "bgcolor", "color", "steps", "step"] + } + params = { + key: value for key, value in params.items() if key not in layout_params + } + super().__init__(**params) + + self._indicators = { + "running": pn.indicators.LoadingSpinner( + value=True, + color=self.param.color, + bgcolor=self.param.bgcolor, + size=25, + margin=(15, 0, 0, 5), + ), + "complete": "✔️", + "error": "❌", + } + + self._title_pane = pn.pane.Markdown(self.param.title, align="center") + self._header_row = pn.Row( + self._indicator, + self._title_pane, + sizing_mode="stretch_width", + margin=(0, 5), + ) + self._details_pane = pn.pane.HTML( + self._details, margin=(10, 5, 10, 55), sizing_mode="stretch_width" + ) + self._layout = pn.Card( + self._details_pane, + header=self._header_row, + collapsed=self.param.collapsed, + **layout_params, + ) + + def __panel__(self): + return self._layout + + @param.depends("value") + def _indicator(self): + return self._indicators[self.value] + + @property + def _step_color(self): + return COLORS[self.value] + + def _step_index(self): + if self.step not in self.steps: + return 0 + return self.steps.index(self.step) + + @param.depends("step", "value") + def _details(self): + steps = self.steps + + if not steps: + return "" + + index = self._step_index() + + html = "" + for step in steps[:index]: + html += f"

" + step = steps[index] + html += f"
{step}
" + for step in steps[index + 1 :]: + html += f"
{step}
" + + return html + + def inc(self, step: str): + with param.edit_constant(self): + self.value = "running" + if not step in self.steps: + self.steps = self.steps + [step] + self.step = step + + def reset(self): + with param.edit_constant(self): + self.steps = [] + self.value = self.param.value.default + + def start(self): + with param.edit_constant(self): + self.step = None + self.value = "running" + + def complete(self): + self.value = "complete" + + @contextmanager + def report(self): + self.start() + try: + yield self + except Exception as ex: + self.value = "error" + else: + self.complete() + + +status = Status("Downloading data...", collapsed=False, sizing_mode="stretch_width") + + +def run(_): + with status.report() as progress: + progress.inc("Searching for data...") + time.sleep(1.5) + progress.inc("Downloading data...") + time.sleep(1.5) + progress.inc("Validating data...") + time.sleep(1.5) + + +run_button = pn.widgets.Button(name="Run", on_click=run) + +pn.Column( + status, + run_button, +).servable() +``` + +![Panel Status](https://user-images.githubusercontent.com/42288570/275434435-dc737d33-4458-474f-9bc3-17e7bf807896.gif) From 5a09714eb4d75d8cbb298b534358cd6ce441aefb Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Mon, 16 Oct 2023 08:29:51 +0000 Subject: [PATCH 17/20] fix title issue --- doc/how_to/streamlit_migration/chat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/how_to/streamlit_migration/chat.md b/doc/how_to/streamlit_migration/chat.md index bc1e0e76df..753d86d091 100644 --- a/doc/how_to/streamlit_migration/chat.md +++ b/doc/how_to/streamlit_migration/chat.md @@ -338,4 +338,4 @@ pn.Column( ).servable() ``` -![Panel Status](https://user-images.githubusercontent.com/42288570/275434435-dc737d33-4458-474f-9bc3-17e7bf807896.gif) +![Panel Status](https://user-images.githubusercontent.com/42288570/275440464-5a610fd8-b1c9-4c1e-8f5e-c9a9f407bc36.gif) From 8eaf74a95c6ebfe84b8f29f0d39b5dcbeefc6337 Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Mon, 16 Oct 2023 08:33:04 +0000 Subject: [PATCH 18/20] update code --- doc/how_to/streamlit_migration/chat.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/how_to/streamlit_migration/chat.md b/doc/how_to/streamlit_migration/chat.md index 753d86d091..fc3060a491 100644 --- a/doc/how_to/streamlit_migration/chat.md +++ b/doc/how_to/streamlit_migration/chat.md @@ -228,7 +228,7 @@ class Status(pn.viewable.Viewer): color=self.param.color, bgcolor=self.param.bgcolor, size=25, - margin=(15, 0, 0, 5), + # margin=(15, 0, 0, 0), ), "complete": "✔️", "error": "❌", @@ -236,7 +236,7 @@ class Status(pn.viewable.Viewer): self._title_pane = pn.pane.Markdown(self.param.title, align="center") self._header_row = pn.Row( - self._indicator, + pn.panel(self._indicator, sizing_mode="fixed", width=40, align="center"), self._title_pane, sizing_mode="stretch_width", margin=(0, 5), @@ -286,7 +286,7 @@ class Status(pn.viewable.Viewer): return html - def inc(self, step: str): + def progress(self, step: str): with param.edit_constant(self): self.value = "running" if not step in self.steps: @@ -310,7 +310,7 @@ class Status(pn.viewable.Viewer): def report(self): self.start() try: - yield self + yield self.progress except Exception as ex: self.value = "error" else: @@ -322,11 +322,11 @@ status = Status("Downloading data...", collapsed=False, sizing_mode="stretch_wid def run(_): with status.report() as progress: - progress.inc("Searching for data...") + progress("Searching for data...") time.sleep(1.5) - progress.inc("Downloading data...") + progress("Downloading data...") time.sleep(1.5) - progress.inc("Validating data...") + progress("Validating data...") time.sleep(1.5) From 865a87413c3834903f456b89493251a3bd16ec67 Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Mon, 16 Oct 2023 09:41:33 +0000 Subject: [PATCH 19/20] review chat migration guide --- doc/how_to/streamlit_migration/chat.md | 35 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/doc/how_to/streamlit_migration/chat.md b/doc/how_to/streamlit_migration/chat.md index fc3060a491..059fcd1038 100644 --- a/doc/how_to/streamlit_migration/chat.md +++ b/doc/how_to/streamlit_migration/chat.md @@ -4,20 +4,21 @@ Both Streamlit and Panel provides special components to help you build conversat | Streamlit | Panel | Description | | -------------------- | ------------------- | -------------------------------------- | -| [`st.chat_message`](https://docs.streamlit.io/library/api-reference/chat/st.chat_message) | [`pn.chat.ChatEntry`](../../../examples/reference/chat/ChatEntry.ipynb) | Display a single chat message | -| [`st_chat_input`](https://docs.streamlit.io/library/api-reference/chat/st.chat_input) | | Input a chat message | -| `st.status` | | Display output of long-running tasks in a container | -| | [`pn.chat.ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) | Display multiple chat messages | -| | [`pn.chat.ChatInterface`](../../../examples/reference/chat/ChatInterface.ipynb) | High-level, easy to use chat interface | -| `langchain.callbacks.StreamlitCallbackHandler` | [`pn.chat.PanelCallbackHandler`](../../../examples/reference/chat/ChatInterface.ipynb) | Display the thoughts and actions of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | +| [`chat_message`](https://docs.streamlit.io/library/api-reference/chat/st.chat_message) | [`ChatEntry`](../../../examples/reference/chat/ChatEntry.ipynb) | Display a chat message | +| [`chat_input`](https://docs.streamlit.io/library/api-reference/chat/st.chat_input) | | Input a chat message | +| [`status`](https://docs.streamlit.io/library/api-reference/status/st.status) | | Display the output of long-running tasks in a container | +| | [`ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) | Display multiple chat messages | +| | [`ChatInterface`](../../../examples/reference/chat/ChatInterface.ipynb) | High-level, easy to use chat interface | +| [`StreamlitCallbackHandler`](https://python.langchain.com/docs/integrations/callbacks/streamlit) | [`PanelCallbackHandler`](../../../examples/reference/chat/ChatInterface.ipynb) | Display the thoughts and actions of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | +| [`StreamlitChatMessageHistory`](https://python.langchain.com/docs/integrations/memory/streamlit_chat_message_history) | | Persists the memory of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | -The starting point for most Panel users is the *high-level* `ChatInterface`. Not the *low-level* `ChatEntry` and `ChatFeed` components. +The starting point for most Panel users is the *high-level* [`ChatInterface`](../../../examples/reference/chat/ChatInterface.ipyn), not the *low-level* [`ChatEntry`](../../../examples/reference/chat/ChatEntry.ipynb) and [`ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) components. -You can find specialized chat components and examples at [panel-chat-examples/](https://holoviz-topics.github.io/panel-chat-examples/). +For inspiration check out the many chat components and examples at [panel-chat-examples](https://holoviz-topics.github.io/panel-chat-examples/). ## Chat Message -Lets see how-to migrate an app using `st.chat_message` +Lets see how-to migrate an app that is using `st.chat_message`. ### Streamlit Chat Message Example @@ -49,6 +50,8 @@ pn.chat.ChatEntry(value=message, user="user").servable() ## Chat Input +Lets see how-to migrate an app that is using `st.chat_message` + ### Streamlit Chat Input ```python @@ -65,7 +68,7 @@ if prompt: Panel does not provide a dedicated *chat input* component because it is built into Panels high-level `ChatInterface`. -Below we will show you how to build a custom `ChatInput` widget. +Below we will show you how to build and use a custom `ChatInput` widget. ```python import param @@ -127,8 +130,11 @@ class ChatInput(pn.viewable.Viewer): def _update_value(self, value, event): self.value = value or self.value self._text_input.value = "" +``` +Let us use the custom `ChatInput` widget. +```Python chat_input = ChatInput(placeholder="Say something") @@ -146,6 +152,8 @@ pn.Column(message, chat_input, margin=50).servable() ## Chat Status +Lets see how-to migrate an app that is using `st.status`. + ### Streamlit Chat Status ```python @@ -167,9 +175,9 @@ st.button("Run") ### Panel Chat Status -Panel does not provide a dedicated *status* component because it is built into Panels high-level `ChatInterface`. Furthermore Panel provides a lot of other built in *indicators*. Check out the [Indicators Gallery](https://panel.holoviz.org/reference/index.html#indicators). +Panel does not provide a dedicated *status* component. Instead it is built into Panels high-level `ChatInterface` as well as provided by a long list of alternative Panel [*indicator components*](https://panel.holoviz.org/reference/index.html#indicators). -Below we will show you how to build a custom `Status` indicator. +Below we will show you how to build and use a custom `Status` indicator. ```python import time @@ -315,8 +323,11 @@ class Status(pn.viewable.Viewer): self.value = "error" else: self.complete() +``` +Let us use the custom `Status` indicator. +```python status = Status("Downloading data...", collapsed=False, sizing_mode="stretch_width") From 872b744021c4221769b3338f989e9be35a3a7eb8 Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Mon, 16 Oct 2023 09:50:25 +0000 Subject: [PATCH 20/20] fix spell errors --- doc/how_to/streamlit_migration/chat.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/how_to/streamlit_migration/chat.md b/doc/how_to/streamlit_migration/chat.md index 059fcd1038..fa042f8867 100644 --- a/doc/how_to/streamlit_migration/chat.md +++ b/doc/how_to/streamlit_migration/chat.md @@ -10,7 +10,7 @@ Both Streamlit and Panel provides special components to help you build conversat | | [`ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) | Display multiple chat messages | | | [`ChatInterface`](../../../examples/reference/chat/ChatInterface.ipynb) | High-level, easy to use chat interface | | [`StreamlitCallbackHandler`](https://python.langchain.com/docs/integrations/callbacks/streamlit) | [`PanelCallbackHandler`](../../../examples/reference/chat/ChatInterface.ipynb) | Display the thoughts and actions of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | -| [`StreamlitChatMessageHistory`](https://python.langchain.com/docs/integrations/memory/streamlit_chat_message_history) | | Persists the memory of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | +| [`StreamlitChatMessageHistory`](https://python.langchain.com/docs/integrations/memory/streamlit_chat_message_history) | | Persist the memory of a [LangChain](https://python.langchain.com/docs/get_started/introduction) agent | The starting point for most Panel users is the *high-level* [`ChatInterface`](../../../examples/reference/chat/ChatInterface.ipyn), not the *low-level* [`ChatEntry`](../../../examples/reference/chat/ChatEntry.ipynb) and [`ChatFeed`](../../../examples/reference/chat/ChatFeed.ipynb) components. @@ -50,7 +50,7 @@ pn.chat.ChatEntry(value=message, user="user").servable() ## Chat Input -Lets see how-to migrate an app that is using `st.chat_message` +Lets see how-to migrate an app that is using `st.chat_input`. ### Streamlit Chat Input @@ -175,7 +175,7 @@ st.button("Run") ### Panel Chat Status -Panel does not provide a dedicated *status* component. Instead it is built into Panels high-level `ChatInterface` as well as provided by a long list of alternative Panel [*indicator components*](https://panel.holoviz.org/reference/index.html#indicators). +Panel does not provide a dedicated *status* component. Instead it is built into Panels high-level `ChatInterface` as well as provided by a long list of alternative Panel [*indicators*](https://panel.holoviz.org/reference/index.html#indicators). Below we will show you how to build and use a custom `Status` indicator.