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 0000000000..27f4c1e237 Binary files /dev/null and b/doc/_static/images/panel_chat_entry.png differ diff --git a/doc/_static/images/panel_chat_input.png b/doc/_static/images/panel_chat_input.png new file mode 100644 index 0000000000..661ae63bd5 Binary files /dev/null and b/doc/_static/images/panel_chat_input.png differ diff --git a/doc/_static/images/streamlit_chat_input.png b/doc/_static/images/streamlit_chat_input.png new file mode 100644 index 0000000000..cce35d23f0 Binary files /dev/null and b/doc/_static/images/streamlit_chat_input.png differ diff --git a/doc/_static/images/streamlit_chat_message.png b/doc/_static/images/streamlit_chat_message.png new file mode 100644 index 0000000000..8e4596bcc8 Binary files /dev/null and b/doc/_static/images/streamlit_chat_message.png differ 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', diff --git a/doc/how_to/streamlit_migration/chat.md b/doc/how_to/streamlit_migration/chat.md new file mode 100644 index 0000000000..fa042f8867 --- /dev/null +++ b/doc/how_to/streamlit_migration/chat.md @@ -0,0 +1,352 @@ +# Create Chat Interfaces + +Both Streamlit and Panel provides special components to help you build conversational apps. + +| Streamlit | Panel | Description | +| -------------------- | ------------------- | -------------------------------------- | +| [`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) | | 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. + +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 that is using `st.chat_message`. + +### Streamlit Chat Message Example + +```python +import streamlit as st + +with st.chat_message("user"): + st.image("https://streamlit.io/images/brand/streamlit-logo-primary-colormark-darktext.png") + st.write("# A faster way to build and share data apps") +``` + +![Streamlit chat_entry](../../_static/images/streamlit_chat_message.png) + +### Panel Chat Message Example + +```python +import panel as pn + +pn.extension(design="material") + +message = pn.Column( + "https://panel.holoviz.org/_images/logo_horizontal_light_theme.png", + "# The powerful data exploration & web app framework for Python" +) +pn.chat.ChatEntry(value=message, user="user").servable() +``` + +![Panel ChatEntry](../../_static/images/panel_chat_entry.png) + +## Chat Input + +Lets see how-to migrate an app that is using `st.chat_input`. + +### Streamlit Chat Input + +```python +import streamlit as st + +prompt = st.chat_input("Say something") +if prompt: + st.write(f"User has sent the following prompt: {prompt}") +``` + +![Streamlit chat_input](../../_static/images/streamlit_chat_input.png) + +### Panel Chat Input + +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 and use a custom `ChatInput` widget. + +```python +import param + +import panel as pn + +pn.extension(design="material") + + +class ChatInput(pn.viewable.Viewer): + value = param.String() + + disabled = param.Boolean() + max_length = param.Integer(default=5000) + placeholder = param.String("Send a message") + + def __init__(self, **params): + layout_params = { + key: value + for key, value in params.items() + if not key in ["value", "placeholder", "disabled", "max_length"] + } + params = { + key: value for key, value in params.items() if key not in layout_params + } + + super().__init__(**params) + + self._text_input = pn.widgets.TextInput( + align="center", + disabled=self.param.disabled, + max_length=self.param.max_length, + name="Message", + placeholder=self.param.placeholder, + sizing_mode="stretch_width", + ) + self._submit_button = pn.widgets.Button( + align="center", + disabled=self.param.disabled, + icon="send", + margin=(18, 5, 10, 0), + name="", + sizing_mode="fixed", + ) + pn.bind( + self._update_value, + value=self._text_input, + event=self._submit_button, + watch=True, + ) + + self._layout = pn.Row( + self._text_input, self._submit_button, align="center", **layout_params + ) + + def __panel__(self): + return self._layout + + 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") + + +@pn.depends(chat_input.param.value) +def message(prompt): + if not prompt: + return "" + return f"User has sent the following prompt: {prompt}" + + +pn.Column(message, chat_input, margin=50).servable() +``` + +![Panel ChatInput](../../_static/images/panel_chat_input.png) + +## Chat Status + +Lets see how-to migrate an app that is using `st.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. 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. + +```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, 0), + ), + "complete": "✔️", + "error": "❌", + } + + self._title_pane = pn.pane.Markdown(self.param.title, align="center") + self._header_row = pn.Row( + pn.panel(self._indicator, sizing_mode="fixed", width=40, align="center"), + 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}
" + step = steps[index] + html += f"
{step}
" + for step in steps[index + 1 :]: + html += f"
{step}
" + + return html + + def progress(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.progress + except Exception as ex: + self.value = "error" + else: + self.complete() +``` + +Let us use the custom `Status` indicator. + +```python +status = Status("Downloading data...", collapsed=False, sizing_mode="stretch_width") + + +def run(_): + with status.report() as progress: + progress("Searching for data...") + time.sleep(1.5) + progress("Downloading data...") + time.sleep(1.5) + progress("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/275440464-5a610fd8-b1c9-4c1e-8f5e-c9a9f407bc36.gif) diff --git a/doc/how_to/streamlit_migration/index.md b/doc/how_to/streamlit_migration/index.md index b587edee46..62f9404a0e 100644 --- a/doc/how_to/streamlit_migration/index.md +++ b/doc/how_to/streamlit_migration/index.md @@ -77,6 +77,13 @@ How to improve the performance with caching How to store state for a session ::: +:::{grid-item-card} {octicon}`dependabottack;2.5em;sd-mr-1 sd-animate-grow50` Chat Interfaces +:link: chat +:link-type: doc + +How to create create chat interfaces +::: + :::{grid-item-card} {octicon}`stack;2.5em;sd-mr-1 sd-animate-grow50` Multi Page Apps :link: multipage_apps :link-type: doc diff --git a/examples/reference/widgets/ChatEntry.ipynb b/examples/reference/chat/ChatEntry.ipynb similarity index 98% rename from examples/reference/widgets/ChatEntry.ipynb rename to examples/reference/chat/ChatEntry.ipynb index 16fb1c8f1e..a16fb9fe8e 100644 --- a/examples/reference/widgets/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/widgets/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb similarity index 93% rename from examples/reference/widgets/ChatFeed.ipynb rename to examples/reference/chat/ChatFeed.ipynb index f421b489f1..7334706f59 100644 --- a/examples/reference/widgets/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" @@ -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/examples/reference/widgets/ChatInterface.ipynb b/examples/reference/chat/ChatInterface.ipynb similarity index 96% rename from examples/reference/widgets/ChatInterface.ipynb rename to examples/reference/chat/ChatInterface.ipynb index 6e55273707..d186b6f428 100644 --- a/examples/reference/widgets/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/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/__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..11ed244c91 --- /dev/null +++ b/panel/chat/__init__.py @@ -0,0 +1,43 @@ +""" +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 .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__ = ( + "ChatEntry", + "ChatFeed", + "ChatInterface", + "ChatReactionIcons", + "PanelCallbackHandler", +) diff --git a/panel/chat/entry.py b/panel/chat/entry.py new file mode 100644 index 0000000000..7ac6e52546 --- /dev/null +++ b/panel/chat/entry.py @@ -0,0 +1,572 @@ +""" +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..0e1cbe1d33 --- /dev/null +++ b/panel/chat/feed.py @@ -0,0 +1,621 @@ +""" +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 +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/icon.py b/panel/chat/icon.py new file mode 100644 index 0000000000..63d91670a2 --- /dev/null +++ b/panel/chat/icon.py @@ -0,0 +1,220 @@ +""" +The icon module provides a low-level API for rendering chat related icons. +""" + +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}`); + data.fill = "currentColor"; + setTimeout(() => data.fill = "none", 50); + """} + + _stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_copy_icon.css"] diff --git a/panel/chat/interface.py b/panel/chat/interface.py new file mode 100644 index 0000000000..9706a47f72 --- /dev/null +++ b/panel/chat/interface.py @@ -0,0 +1,458 @@ +""" +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 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/widgets/langchain.py b/panel/chat/langchain.py similarity index 52% rename from panel/widgets/langchain.py rename to panel/chat/langchain.py index 58219a332f..109299d762 100644 --- a/panel/widgets/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.widgets 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_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/widgets/test_chat.py b/panel/tests/chat/test_feed.py similarity index 55% rename from panel/tests/widgets/test_chat.py rename to panel/tests/chat/test_feed.py index 1ba33ca5a8..954d10bc9f 100644 --- a/panel/tests/widgets/test_chat.py +++ b/panel/tests/chat/test_feed.py @@ -1,22 +1,17 @@ import asyncio -import datetime import time from unittest.mock import MagicMock import pytest -from panel import Param, bind -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.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", @@ -27,287 +22,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") - 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): @@ -662,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: @@ -931,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 new file mode 100644 index 0000000000..3c06d39d04 --- /dev/null +++ b/panel/tests/chat/test_icon.py @@ -0,0 +1,62 @@ +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 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/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" diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index ace965db4d..36fb5ca10a 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 @@ -51,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 diff --git a/panel/widgets/chat.py b/panel/widgets/chat.py deleted file mode 100644 index 36cdc7307d..0000000000 --- a/panel/widgets/chat.py +++ /dev/null @@ -1,1646 +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 -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, -) -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 ..reactive import ReactiveHTML -from ..viewable import Viewable -from .base import CompositeWidget, Widget -from .button import Button -from .input import FileInput, TextInput - -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 - -# if user cannot connect to internet -MISSING_SVG = """ - - - - - - -""" # noqa: E501 - -MISSING_FILLED_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 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/widgets/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. - - 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/widgets/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/widgets/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/widgets/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