diff --git a/CHANGELOG.md b/CHANGELOG.md index 9634d44ce..45216e7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## New features +* Added support for bookmarking Shiny applications. Bookmarking allows users to save the current state of an application and return to it later. This feature is available in both Shiny Core and Shiny Express. (#1870, #1915, #1919, #1920, #1922, #1934, #1938, #1945) + * To enable bookmarking in Express mode, set `shiny.express.app_opts(bookmark_store=)` during the app's initial construction. + * To enable bookmarking in Core mode, set `shiny.App(bookmark_store=)` when constructing the `app` object. + +* Added a new `.enable_bookmarking(client)` method to `ui.Chat()`. This method will attach bookmark hooks to save and restore the chat's messages and client state. (#1951) + * Both `ui.Chat()` and `ui.MarkdownStream()` now support arbirary Shiny UI elements inside of messages. This allows for gathering input from the user (e.g., `ui.input_select()`), displaying of rich output (e.g., `render.DataGrid()`), and more. (#1868) * Added a new `.message_stream_context()` method to `ui.Chat()`. This context manager is a useful alternative to `.append_message_stream()` when you want to: (1) Nest a stream within another and/or diff --git a/pyproject.toml b/pyproject.toml index 34e8f238f..c51a7d97f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ dev = [ "langsmith<0.3", "openai", "ollama", + "chatlas>=0.6.1", "tokenizers", "aiohttp", "beautifulsoup4", diff --git a/shiny/templates/chat/llm-enterprise/aws-bedrock-anthropic/app.py b/shiny/templates/chat/llm-enterprise/aws-bedrock-anthropic/app.py index a1fa87570..324786a91 100644 --- a/shiny/templates/chat/llm-enterprise/aws-bedrock-anthropic/app.py +++ b/shiny/templates/chat/llm-enterprise/aws-bedrock-anthropic/app.py @@ -28,6 +28,9 @@ chat = ui.Chat(id="chat") chat.ui() +# Store chat state in the url when an "assistant" response occurs +chat.enable_bookmarking(chat_client, bookmark_store="url") + # Define a callback to run when the user submits a message @chat.on_user_submit diff --git a/shiny/templates/chat/llm-enterprise/azure-openai/app.py b/shiny/templates/chat/llm-enterprise/azure-openai/app.py index 0db47872d..2f071318a 100644 --- a/shiny/templates/chat/llm-enterprise/azure-openai/app.py +++ b/shiny/templates/chat/llm-enterprise/azure-openai/app.py @@ -33,6 +33,9 @@ ) chat.ui() +# Store chat state in the url when an "assistant" response occurs +chat.enable_bookmarking(chat_client, bookmark_store="url") + # Define a callback to run when the user submits a message @chat.on_user_submit diff --git a/shiny/templates/chat/llms/anthropic/app.py b/shiny/templates/chat/llms/anthropic/app.py index c18082549..ded48bd0e 100644 --- a/shiny/templates/chat/llms/anthropic/app.py +++ b/shiny/templates/chat/llms/anthropic/app.py @@ -33,6 +33,9 @@ ) chat.ui() +# Store chat state in the url when an "assistant" response occurs +chat.enable_bookmarking(chat_client, bookmark_store="url") + # Generate a response when the user submits a message @chat.on_user_submit diff --git a/shiny/templates/chat/llms/google/app.py b/shiny/templates/chat/llms/google/app.py index bdb04946c..75a66bfa5 100644 --- a/shiny/templates/chat/llms/google/app.py +++ b/shiny/templates/chat/llms/google/app.py @@ -29,6 +29,9 @@ chat = ui.Chat(id="chat") chat.ui() +# Store chat state in the url when an "assistant" response occurs +chat.enable_bookmarking(chat_client, bookmark_store="url") + # Generate a response when the user submits a message @chat.on_user_submit diff --git a/shiny/templates/chat/llms/langchain/app.py b/shiny/templates/chat/llms/langchain/app.py index 8ecc5c0ef..cbddce706 100644 --- a/shiny/templates/chat/llms/langchain/app.py +++ b/shiny/templates/chat/llms/langchain/app.py @@ -34,6 +34,9 @@ ) chat.ui() +# Store chat state in the url when an "assistant" response occurs +chat.enable_bookmarking(chat_client, bookmark_store="url") + # Define a callback to run when the user submits a message @chat.on_user_submit diff --git a/shiny/templates/chat/llms/ollama/app.py b/shiny/templates/chat/llms/ollama/app.py index 90ab86bbd..e5d0db361 100644 --- a/shiny/templates/chat/llms/ollama/app.py +++ b/shiny/templates/chat/llms/ollama/app.py @@ -25,6 +25,9 @@ ) chat.ui() +# Store chat state in the url when an "assistant" response occurs +chat.enable_bookmarking(chat_client, bookmark_store="url") + # Generate a response when the user submits a message @chat.on_user_submit diff --git a/shiny/templates/chat/llms/openai/app.py b/shiny/templates/chat/llms/openai/app.py index 4b7ebea02..009bce181 100644 --- a/shiny/templates/chat/llms/openai/app.py +++ b/shiny/templates/chat/llms/openai/app.py @@ -33,6 +33,9 @@ ) chat.ui() +# Store chat state in the url when an "assistant" response occurs +chat.enable_bookmarking(chat_client, bookmark_store="url") + # Generate a response when the user submits a message @chat.on_user_submit diff --git a/shiny/ui/_chat.py b/shiny/ui/_chat.py index 5dd8661bc..e8b768230 100644 --- a/shiny/ui/_chat.py +++ b/shiny/ui/_chat.py @@ -3,6 +3,7 @@ import inspect from contextlib import asynccontextmanager from typing import ( + TYPE_CHECKING, Any, AsyncIterable, Awaitable, @@ -23,10 +24,20 @@ from .. import _utils, reactive from .._deprecated import warn_deprecated from .._docstring import add_example +from .._utils import CancelCallback, wrap_async +from ..bookmark import BookmarkState, RestoreState +from ..bookmark._types import BookmarkStore from ..module import ResolvedId, resolve_id -from ..session import require_active_session, session_context -from ..types import MISSING, MISSING_TYPE, NotifyException +from ..session import get_current_session, require_active_session, session_context +from ..types import MISSING, MISSING_TYPE, Jsonifiable, NotifyException from ..ui.css import CssUnit, as_css_unit +from ._chat_bookmark import ( + BookmarkCancelCallback, + ClientWithState, + get_chatlas_state, + is_chatlas_chat_client, + set_chatlas_state, +) from ._chat_normalize import normalize_message, normalize_message_chunk from ._chat_provider_types import ( AnthropicMessage, @@ -43,6 +54,13 @@ from ._html_deps_py_shiny import chat_deps from .fill import as_fill_item, as_fillable_container +if TYPE_CHECKING: + + import chatlas + +else: + chatlas = object + __all__ = ( "Chat", "ChatExpress", @@ -216,6 +234,7 @@ def __init__( # Keep track of effects so we can destroy them when the chat is destroyed self._effects: list[reactive.Effect_] = [] + self._cancel_bookmarking_callbacks: CancelCallback | None = None # Initialize chat state and user input effect with session_context(self._session): @@ -238,11 +257,17 @@ async def _mock_task() -> str: # TODO: deprecate messages once we start promoting managing LLM message # state through other means - @reactive.effect - async def _init_chat(): + async def _append_init_messages(): for msg in messages: await self.append_message(msg) + @reactive.effect + async def _init_chat(): + await _append_init_messages() + + self._append_init_messages = _append_init_messages + self._init_chat = _init_chat + # When user input is submitted, transform, and store it in the chat state # (and make sure this runs before other effects since when the user # calls `.messages()`, they should get the latest user input) @@ -1308,10 +1333,21 @@ def destroy(self): """ Destroy the chat instance. """ + self._destroy_effects() + self._destroy_bookmarking() + + def _destroy_effects(self): for x in self._effects: x.destroy() self._effects.clear() + def _destroy_bookmarking(self): + if not self._cancel_bookmarking_callbacks: + return + + self._cancel_bookmarking_callbacks() + self._cancel_bookmarking_callbacks = None + async def _remove_loading_message(self): await self._send_custom_message("shiny-chat-remove-loading-message", None) @@ -1325,6 +1361,192 @@ async def _send_custom_message(self, handler: str, obj: ClientMessage | None): }, ) + def enable_bookmarking( + self, + client: ClientWithState | chatlas.Chat[Any, Any], + /, + *, + bookmark_on: Optional[Literal["response"]] = "response", + ) -> CancelCallback: + """ + Enable bookmarking for the chat instance. + + This method registers `on_bookmark` and `on_restore` hooks on `session.bookmark` + (:class:`shiny.bookmark.Bookmark`) to save/restore chat state on both the `Chat` + and `client=` instances. In order for this method to actually work correctly, a + `bookmark_store=` must be specified in `shiny.App()`. + + Parameters + ---------- + client + The chat client instance to use for bookmarking. This can be a Chat model + provider from [chatlas](https://posit-dev.github.io/chatlas/), or more + generally, an instance following the `ClientWithState` protocol. + bookmark_on + The event to trigger the bookmarking on. Supported values include: + + - `"response"` (the default): a bookmark is triggered when the assistant is done responding. + - `None`: no bookmark is triggered + + When this method triggers a bookmark, it also updates the URL query string to reflect the bookmarked state. + + + Raises + ------ + ValueError + If the Shiny App does have bookmarking enabled. + + Returns + ------- + : + A callback to cancel the bookmarking hooks. + """ + from ..express._stub_session import ExpressStubSession + + session = get_current_session() + if session is None or isinstance(session, ExpressStubSession): + return BookmarkCancelCallback(lambda: None) + + if session.bookmark.store == "disable": + raise ValueError( + "Bookmarking requires a `bookmark_store` to be set. " + "Please set `bookmark_store=` in `shiny.App()` or `shiny.express.app_opts()." + ) + + resolved_bookmark_id_str = str(self.id) + resolved_bookmark_id_msgs_str = resolved_bookmark_id_str + "--msgs" + get_state: Callable[[], Awaitable[Jsonifiable]] + set_state: Callable[[Jsonifiable], Awaitable[None]] + + # Retrieve get_state/set_state functions from the client + if isinstance(client, ClientWithState): + # Do client with state stuff here + get_state = wrap_async(client.get_state) + set_state = wrap_async(client.set_state) + + elif is_chatlas_chat_client(client): + + get_state = get_chatlas_state(client) + set_state = set_chatlas_state(client) + + else: + raise ValueError( + "Bookmarking requires a client that supports " + "`async def get_state(self) -> shiny.types.Jsonifiable` (which returns an object that can be used when bookmarking to save the state of the `client=`) and " + "`async def set_state(self, value: Jsonifiable)` (which should restore the `client=`'s state given the `state=`)." + ) + + # Reset prior bookmarking hooks + self._destroy_bookmarking() + + # Must use `root_session` as the id is already resolved. :-/ + # Using a proxy session would double-encode the proxy-prefix + root_session = session.root_scope() + root_session.bookmark.exclude.append(self.id + "_user_input") + + # ########### + # Bookmarking + + if bookmark_on is not None: + + # When ever the bookmark is requested, update the query string (indep of store type) + @root_session.bookmark.on_bookmarked + async def _(url: str): + await session.bookmark.update_query_string(url) + + if bookmark_on == "response": + + @reactive.effect + @reactive.event(lambda: self.messages(format=MISSING), ignore_init=True) + async def _(): + messages = self.messages(format=MISSING) + + if len(messages) == 0: + return + + last_message = messages[-1] + + if last_message.get("role") == "assistant": + await session.bookmark() + + ############### + # Client Bookmarking + + @root_session.bookmark.on_bookmark + async def _on_bookmark_client(state: BookmarkState): + if resolved_bookmark_id_str in state.values: + raise ValueError( + f'Bookmark value with id (`"{resolved_bookmark_id_str}"`) already exists.' + ) + + with reactive.isolate(): + state.values[resolved_bookmark_id_str] = await get_state() + + @root_session.bookmark.on_restore + async def _on_restore_client(state: RestoreState): + if resolved_bookmark_id_str not in state.values: + return + + # Retrieve the chat turns from the bookmark state + info = state.values[resolved_bookmark_id_str] + await set_state(info) + + ############### + # UI Bookmarking + + @root_session.bookmark.on_bookmark + def _on_bookmark_ui(state: BookmarkState): + if resolved_bookmark_id_msgs_str in state.values: + raise ValueError( + f'Bookmark value with id (`"{resolved_bookmark_id_msgs_str}"`) already exists.' + ) + + with reactive.isolate(): + # This does NOT contain the `chat.ui(messages=)` values. + # When restoring, the `chat.ui(messages=)` values will need to be kept + # and the `ui.Chat(messages=)` values will need to be reset + state.values[resolved_bookmark_id_msgs_str] = self.messages( + format=MISSING + ) + + # Attempt to stop the initialization of the `ui.Chat(messages=)` messages + self._init_chat.destroy() + + @root_session.bookmark.on_restore + async def _on_restore_ui(state: RestoreState): + # Do not call `self.clear_messages()` as it will clear the + # `chat.ui(messages=)` in addition to the `self.messages()` + # (which is not what we want). + + # We always want to keep the `chat.ui(messages=)` values + # and `self.messages()` are never initialized due to + # calling `self._init_chat.destroy()` above + + if resolved_bookmark_id_msgs_str not in state.values: + # If no messages to restore, display the `__init__(messages=)` messages + await self._append_init_messages() + return + + msgs: list[Any] = state.values[resolved_bookmark_id_msgs_str] + if not isinstance(msgs, list): + raise ValueError( + f"Bookmark value with id (`{resolved_bookmark_id_msgs_str}`) must be a list of messages." + ) + + for message_dict in msgs: + await self.append_message(message_dict) + + def _cancel_bookmarking(): + _on_bookmark_client() + _on_bookmark_ui() + _on_restore_client() + _on_restore_ui() + + # Store the callbacks to be able to destroy them later + self._cancel_bookmarking_callbacks = _cancel_bookmarking + + return BookmarkCancelCallback(_cancel_bookmarking) + @add_example("app-express.py") class ChatExpress(Chat): @@ -1365,6 +1587,7 @@ def ui( kwargs Additional attributes for the chat container element. """ + return chat_ui( id=self.id, messages=messages, @@ -1376,6 +1599,58 @@ def ui( **kwargs, ) + def enable_bookmarking( + self, + client: ClientWithState | chatlas.Chat[Any, Any], + /, + *, + bookmark_store: Optional[BookmarkStore] = None, + bookmark_on: Optional[Literal["response"]] = "response", + ) -> CancelCallback: + """ + Enable bookmarking for the chat instance. + + This method registers `on_bookmark` and `on_restore` hooks on `session.bookmark` + (:class:`shiny.bookmark.Bookmark`) to save/restore chat state on both the `Chat` + and `client=` instances. In order for this method to actually work correctly, a + `bookmark_store=` must be specified in `shiny.express.app_opts()`. + + Parameters + ---------- + client + The chat client instance to use for bookmarking. This can be a Chat model + provider from [chatlas](https://posit-dev.github.io/chatlas/), or more + generally, an instance following the `ClientWithState` protocol. + bookmark_store + A convenience parameter to set the `shiny.express.app_opts(bookmark_store=)` + which is required for bookmarking (and `.enable_bookmarking()`). If `None`, + no value will be set. + bookmark_on + The event to trigger the bookmarking on. Supported values include: + + - `"response"` (the default): a bookmark is triggered when the assistant is done responding. + - `None`: no bookmark is triggered + + When this method triggers a bookmark, it also updates the URL query string to reflect the bookmarked state. + + Raises + ------ + ValueError + If the Shiny App does have bookmarking enabled. + + Returns + ------- + : + A callback to cancel the bookmarking hooks. + """ + + if bookmark_store is not None: + from ..express import app_opts + + app_opts(bookmark_store=bookmark_store) + + return super().enable_bookmarking(client, bookmark_on=bookmark_on) + @add_example(ex_dir="../api-examples/Chat") def chat_ui( diff --git a/shiny/ui/_chat_bookmark.py b/shiny/ui/_chat_bookmark.py new file mode 100644 index 000000000..07cb5bca3 --- /dev/null +++ b/shiny/ui/_chat_bookmark.py @@ -0,0 +1,95 @@ +import importlib.util +from typing import Any, Awaitable, Callable, Protocol, runtime_checkable + +from htmltools import TagChild + +from .._utils import CancelCallback +from ..types import Jsonifiable + +chatlas_is_installed = importlib.util.find_spec("chatlas") is not None + + +def is_chatlas_chat_client(client: Any) -> bool: + if not chatlas_is_installed: + return False + import chatlas + + return isinstance(client, chatlas.Chat) + + +@runtime_checkable +class ClientWithState(Protocol): + async def get_state(self) -> Jsonifiable: ... + + """ + Retrieve JSON-like representation of chat client state. + + This method is used to retrieve the state of the client object when saving a bookmark. + + Returns + ------- + : + A JSON-like representation of the current state of the client. It is not required to be a JSON string but something that can be serialized to JSON without further conversion. + """ + + async def set_state(self, state: Jsonifiable): ... + + """ + Method to set the chat client state. + + This method is used to restore the state of the client when the app is restored from + a bookmark. + + Parameters + ---------- + state + The value to infer the state from. This value will be the JSON capable value + returned by the `get_state()` method (after a round trip through JSON + serialization and unserialization). + """ + + +class BookmarkCancelCallback: + def __init__(self, cancel: CancelCallback): + self.cancel = cancel + + def __call__(self): + self.cancel() + + def tagify(self) -> TagChild: + return "" + + +# Chatlas specific implementation +def get_chatlas_state( + client: Any, +) -> Callable[[], Awaitable[Jsonifiable]]: + + from chatlas import Chat, Turn + + assert isinstance(client, Chat) + + async def get_state() -> Jsonifiable: + + turns: list[Turn[Any]] = client.get_turns() + return [turn.model_dump(mode="json") for turn in turns] + + return get_state + + +def set_chatlas_state( + client: Any, +) -> Callable[[Jsonifiable], Awaitable[None]]: + from chatlas import Chat, Turn + + assert isinstance(client, Chat) + + async def set_state(value: Jsonifiable) -> None: + + if not isinstance(value, list): + raise ValueError("Chatlas bookmark value was not a list of objects") + + turns: list[Turn[Any]] = [Turn.model_validate(turn_obj) for turn_obj in value] + client.set_turns(turns) # pyright: ignore[reportUnknownMemberType] + + return set_state diff --git a/tests/playwright/shiny/bookmark/chat/chatlas/app.py b/tests/playwright/shiny/bookmark/chat/chatlas/app.py new file mode 100644 index 000000000..79e46be04 --- /dev/null +++ b/tests/playwright/shiny/bookmark/chat/chatlas/app.py @@ -0,0 +1,46 @@ +import chatlas + +from shiny.express import ui + +# Set some Shiny page options +ui.page_opts( + title="Hello Shiny Chat", + fillable=True, + fillable_mobile=True, +) + +# Create a chat instance +init_messages = ["""Welcome!"""] +chat = ui.Chat( + id="chat", + messages=init_messages, +) + +# Display it +chat.ui() + +# Goal: Test that chatlas is serializied and deserialized correctly. +# +# Use ChatOpenAI as it does not need credentials until submission to the server. +# However, if we use `.set_turns()` and `.get_turns()`, a submission is never made to the server... therefore we don't need credentials. +chat_client = chatlas.ChatOpenAI( # pyright: ignore[reportUnknownMemberType] + turns=[], + api_key="", +) +chat.enable_bookmarking(chat_client, bookmark_store="url") + + +# Define a callback to run when the user submits a message +@chat.on_user_submit +async def handle_user_input(user_input: str): + mock_msg = f"You said to OpenAI: {user_input}" + chat_client.set_turns( # pyright: ignore[reportUnknownMemberType] + [ + *chat_client.get_turns(), + chatlas.Turn(role="user", contents=user_input), + chatlas.Turn(role="assistant", contents=mock_msg), + ] + ) + + # Append a response to the chat + await chat.append_message(mock_msg) diff --git a/tests/playwright/shiny/bookmark/chat/chatlas/test_bookmark_chatlas.py b/tests/playwright/shiny/bookmark/chat/chatlas/test_bookmark_chatlas.py new file mode 100644 index 000000000..3dab8bba8 --- /dev/null +++ b/tests/playwright/shiny/bookmark/chat/chatlas/test_bookmark_chatlas.py @@ -0,0 +1,29 @@ +import re + +from playwright.sync_api import Page + +from shiny.playwright.controller import Chat +from shiny.run import ShinyAppProc + + +def test_bookmark_chatlas(page: Page, local_app: ShinyAppProc): + + page.goto(local_app.url) + + assert "?" not in page.url + + chat_controller = Chat(page, "chat") + + # Add timeout to potentially avoid WebKit flakiness in CI + chat_controller.expect_messages("Welcome!", timeout=30 * 1000) # 30 seconds + + chat_controller.set_user_input("Testing") + chat_controller.send_user_input() + + chat_controller.expect_messages("Welcome!\nTesting\nYou said to OpenAI: Testing") + + page.wait_for_url(re.compile(r".*\?.*"), timeout=30 * 1000) + + page.reload() + + chat_controller.expect_messages("Welcome!\nTesting\nYou said to OpenAI: Testing") diff --git a/tests/playwright/shiny/bookmark/chat/client_state/app.py b/tests/playwright/shiny/bookmark/chat/client_state/app.py new file mode 100644 index 000000000..a7f0380db --- /dev/null +++ b/tests/playwright/shiny/bookmark/chat/client_state/app.py @@ -0,0 +1,65 @@ +from typing import cast + +from shiny.express import ui +from shiny.types import Jsonifiable, JsonifiableDict + +# Set some Shiny page options +ui.page_opts( + title="Hello Shiny Chat", + fillable=True, + fillable_mobile=True, +) + + +# Create a chat instance +init_messages = ["""Welcome!"""] +chat = ui.Chat( + id="chat", + messages=init_messages, +) + +# Display it +chat.ui() + + +class RepeaterClient: + """ + A simple chat client repeater that echoes back the user's input. + """ + + def __init__(self, *, messages: list[str]): + self.messages = messages + + def append_message(self, message: str) -> str: + msg = f"Repeater: {message}" + self.messages.append(msg) + return msg + + async def get_state(self) -> Jsonifiable: + """ + Get the current state of the chat client. + """ + return cast(JsonifiableDict, {"messages": self.messages}) + + async def set_state(self, state: Jsonifiable) -> None: + """ " + Set the state of the chat client. + """ + assert isinstance(state, dict) + assert "messages" in state + assert isinstance(state["messages"], list) + self.messages = state["messages"] + + +chat_client = RepeaterClient(messages=init_messages) + +chat.enable_bookmarking(chat_client, bookmark_store="url") + + +# Define a callback to run when the user submits a message +@chat.on_user_submit +async def handle_user_input(user_input: str): + + msg = chat_client.append_message(user_input) + # Append a response to the chat + await chat.append_message(msg) diff --git a/tests/playwright/shiny/bookmark/chat/client_state/test_bookmark_chat.py b/tests/playwright/shiny/bookmark/chat/client_state/test_bookmark_chat.py new file mode 100644 index 000000000..3bb20459f --- /dev/null +++ b/tests/playwright/shiny/bookmark/chat/client_state/test_bookmark_chat.py @@ -0,0 +1,28 @@ +import re + +from playwright.sync_api import Page + +from shiny.playwright.controller import Chat +from shiny.run import ShinyAppProc + + +def test_bookmark_chatlas(page: Page, local_app: ShinyAppProc): + + page.goto(local_app.url) + + assert "?" not in page.url + + chat_controller = Chat(page, "chat") + + chat_controller.expect_messages("Welcome!") + + chat_controller.set_user_input("Testing") + chat_controller.send_user_input() + + chat_controller.expect_messages("Welcome!\nTesting\nRepeater: Testing") + + page.wait_for_url(re.compile(r".*\?.*"), timeout=30 * 1000) + + page.reload() + + chat_controller.expect_messages("Welcome!\nTesting\nRepeater: Testing")