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("