From bc1ee87d751427148c730f6833cbb4fb9df3b3e7 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 26 May 2026 12:31:26 -0400 Subject: [PATCH 1/4] feat(py): add batteries-included chat_auto API for chatlas-powered apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add chat_auto_ui(), chat_auto_server(), and ChatExpress.ui_auto() — a high-level API that wires streaming, cancellation, and bookmarking to a chatlas client automatically. Also fix enable_bookmarking() to silently no-op when no bookmark store is configured (both Python and R), so it can be called unconditionally. --- pkg-py/CHANGELOG.md | 23 +- pkg-py/docs/_quarto.yml | 12 + pkg-py/docs/index.qmd | 63 ++- pkg-py/src/shinychat/__init__.py | 4 + pkg-py/src/shinychat/_chat.py | 80 +++- pkg-py/src/shinychat/_chat_auto.py | 383 ++++++++++++++++++ pkg-py/src/shinychat/types/__init__.py | 2 + .../playwright/chat/auto_cancel_core/app.py | 84 ++++ .../test_chat_auto_cancel_core.py | 24 ++ pkg-py/tests/playwright/chat/auto_core/app.py | 66 +++ .../chat/auto_core/test_chat_auto_core.py | 48 +++ .../tests/playwright/chat/auto_express/app.py | 63 +++ .../auto_express/test_chat_auto_express.py | 50 +++ .../playwright/chat/bookmark_no_store/app.py | 39 ++ .../test_bookmark_no_store.py | 19 + pkg-py/tests/pytest/test_chat_auto.py | 249 ++++++++++++ pkg-r/R/chat_restore.R | 3 - 17 files changed, 1163 insertions(+), 49 deletions(-) create mode 100644 pkg-py/src/shinychat/_chat_auto.py create mode 100644 pkg-py/tests/playwright/chat/auto_cancel_core/app.py create mode 100644 pkg-py/tests/playwright/chat/auto_cancel_core/test_chat_auto_cancel_core.py create mode 100644 pkg-py/tests/playwright/chat/auto_core/app.py create mode 100644 pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py create mode 100644 pkg-py/tests/playwright/chat/auto_express/app.py create mode 100644 pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py create mode 100644 pkg-py/tests/playwright/chat/bookmark_no_store/app.py create mode 100644 pkg-py/tests/playwright/chat/bookmark_no_store/test_bookmark_no_store.py create mode 100644 pkg-py/tests/pytest/test_chat_auto.py diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index d8ca82ad..c771e47d 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -5,34 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0] - 2026-05-26 +## [0.4.0] - Unreleased ### New features -* The chat UI now displays model reasoning/thinking content as collapsible panels above assistant responses. Thinking content streams in real-time with animated topic labels. This works with providers that support structured thinking (e.g., Claude's extended thinking via `chatlas`) and with local models that wrap reasoning in `` tags. (#208) - -* Added `chat_greeting()` for creating welcome messages that appear when the chat is empty. Greetings can be set statically via `chat_ui(greeting=)` or dynamically with `Chat.set_greeting()`, and are dismissed when the user sends their first message. A new `{id}_greeting_requested` input fires when the chat is visible, empty, and has no greeting, enabling LLM-generated welcome messages. (#217) - -* Added `enable_cancel` parameter to `chat_ui()` and `Chat.ui()` to show a stop button that lets users cancel an in-progress AI response. Press the stop button or hit Escape to cancel. Wire up the `input._cancel` event to a `chatlas.StreamController` (introduced in chatlas v0.18.0) to connect the UI to your chat provider. (#221) +* Added `chat_auto_ui()`, `chat_auto_server()`, and `Chat.ui_auto()` — a batteries-included API that wires streaming, cancellation, and bookmarking to a chatlas client automatically. Hand it a chatlas client and get a complete chat interface with no manual plumbing. The returned `ChatAutoServer` lets you swap models mid-session (`.client`), reset the conversation (`.clear()`), and access the underlying `Chat` for lower-level operations. * Added `footer` parameter to `chat_ui()` and `Chat.ui()` for displaying arbitrary HTML content below the chat input. Useful for disclaimers, attribution, or interactive toolbars. Styled with sensible defaults and customizable via `--shiny-chat-footer-font-size` and `--shiny-chat-footer-color` CSS custom properties. (#224) -* The chat input now supports history navigation: press Up/Down arrow keys when the input is empty to cycle through previously sent messages. Editing a recalled message locks navigation until the input is cleared. (#222) - -### Improvements +* Added `chat_greeting()` for creating welcome messages that appear when the chat is empty. Greetings can be set statically via `chat_ui(greeting=)` or dynamically with `Chat.set_greeting()`, and are dismissed when the user sends their first message. A new `{id}_greeting_requested` input fires when the chat is visible, empty, and has no greeting, enabling LLM-generated welcome messages. (#217) * Tool result cards now render images and PDFs returned by chatlas tools. When a tool returns `ContentImageInline`, `ContentImageRemote`, or `ContentPDF`, the result is displayed as an inline image or a PDF filename badge. Mixed content lists (e.g., `[ContentText("summary"), content_image_file("plot.png")]`) are rendered with items interleaved in order. Standalone image and PDF content items in turn history are also rendered correctly. (#225) -* Markdown lists where every item is a `` are now rendered as a grid of clickable suggestion cards. Each suggestion's text content becomes both the card label and the value sent on click. To add a short heading above the body text, set the `title` attribute on the span — e.g. `Body text shown on the card.`. Only the body text (not the title) is submitted when the card is clicked. Cards stream in with staggered animations and support keyboard navigation (arrow keys, Home/End) with roving tabindex. (#219) - -* Updated minimum `chatlas` version to `>=0.15.0`. (#208) +* Added `enable_cancel` parameter to `chat_ui()` and `Chat.ui()` to show a stop button that lets users cancel an in-progress AI response. Press the stop button or hit Escape to cancel. Wire up the `input._cancel` event to a `chatlas.StreamController` (introduced in chatlas v0.18.0) to connect the UI to your chat provider. (#221) -### Bug fixes +* Markdown lists where every item is a `` are now rendered as a grid of clickable suggestion cards. Each suggestion's text content becomes both the card label and the value sent on click. To add a short heading above the body text, set the `title` attribute on the span — e.g. `Body text shown on the card.`. Only the body text (not the title) is submitted when the card is clicked. Cards stream in with staggered animations and support keyboard navigation (arrow keys, Home/End) with roving tabindex. (#219) -* Fixed a circular import error triggered when `import shinychat` ran before `shiny` had fully initialized. (#212) +* The chat UI now displays model reasoning/thinking content as collapsible panels above assistant responses. Thinking content streams in real-time with animated topic labels. This works with providers that support structured thinking (e.g., Claude's extended thinking via `chatlas`) and with local models that wrap reasoning in `` tags. (#208) -* Fixed extra newlines appearing when copying user message text. (#209) +### Improvements +* Updated minimum `chatlas` version to `>=0.15.0`. (#208) ## [0.3.2] - 2026-05-21 diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index c6f10ad0..6a9f972c 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -71,6 +71,18 @@ quartodoc: - Chat - chat_ui - chat_greeting + - title: Auto-wired Chat + options: + signature_name: relative + include_imports: false + include_inherited: false + include_attributes: true + include_classes: true + include_functions: true + contents: + - chat_auto_ui + - chat_auto_server + - ChatAutoServer - title: Shiny Express options: signature_name: relative diff --git a/pkg-py/docs/index.qmd b/pkg-py/docs/index.qmd index 8097c4d1..d413d7bb 100644 --- a/pkg-py/docs/index.qmd +++ b/pkg-py/docs/index.qmd @@ -7,7 +7,7 @@ pagetitle: "Get Started" -**shinychat** provides a [Shiny](https://shiny.posit.co/py) toolkit for building generative AI applications like chatbots and streaming content. It works best with [chatlas](https://posit-dev.github.io/chatlas/), but also works great with other LLM frameworks such as LangChain, Pydantic AI, and more. +**shinychat** provides a [Shiny](https://shiny.posit.co/py) toolkit for building generative AI chatbots. It works best with [chatlas](https://posit-dev.github.io/chatlas/), but also supports other LLM frameworks such as LangChain, Pydantic AI, and more. ## Installation @@ -29,22 +29,41 @@ Or, install the development version of shinychat from [GitHub](https://github.co uv pip install git+https://github.com/posit-dev/shinychat.git ``` -## Starter example +## Quick start -With shiny installed, you're ready to run your first shinychat app. Create a new file named `app.py` with the following content: +The fastest way to build a chatbot with shinychat is to hand a [chatlas](https://posit-dev.github.io/chatlas/) client to `ui_auto()` (Express) or `chat_auto_ui()`/`chat_auto_server()` (Core). This gives you streaming, cancellation, bookmarking, and more out of the box — no manual wiring required. + +**Shiny Express** ```{.python file="app.py"} +from chatlas import ChatAnthropic +from shiny.express import app_opts, ui from shinychat.express import Chat -# Create a chat instance and display it +client = ChatAnthropic(system_prompt="You are a helpful assistant.") chat = Chat(id="chat") -chat.ui() +chat.ui_auto(client) -# Define a callback to run when the user submits a message -@chat.on_user_submit -async def handle_user_input(user_input: str): - # Simply echo the user's input back to them - await chat.append_message(f"You said: {user_input}") +ui.page_opts(fillable=True) +app_opts(bookmark_store="url") +``` + +**Shiny Core** + +```{.python file="app.py"} +from shiny import App, ui +from chatlas import ChatAnthropic +from shinychat import chat_auto_ui, chat_auto_server + +app_ui = ui.page_fillable( + chat_auto_ui("chat"), +) + +def server(input, output, session): + client = ChatAnthropic(system_prompt="You are a helpful assistant.") + chat = chat_auto_server("chat", client=client) + +app = App(app_ui, server, bookmark_store="url") ``` To run the app, execute the following command in your terminal (or via the [Shiny extension](https://marketplace.visualstudio.com/items?itemName=Posit.shiny)): @@ -53,12 +72,28 @@ To run the app, execute the following command in your terminal (or via the [Shin uv shiny run --reload app.py ``` +The returned `ChatAutoServer` object provides a read/write `.client` property for swapping models mid-session and `.clear()` for resetting the conversation. For lower-level chat methods such as appending messages, updating the input, or inspecting stream state, use `chat.chat.()` on the underlying `Chat` instance. See the [API reference](api/index.qmd) for the full interface. + +## Lower-level interface + +When you need full control over the server-side logic — or you're using an LLM framework other than chatlas — use `Chat`/`chat_ui` directly. This means wiring up streaming, cancellation, and bookmarking yourself, but gives you complete flexibility over how messages are generated and displayed. + +Here's a minimal echo example: + +```{.python file="app.py"} +from shinychat.express import Chat + +chat = Chat(id="chat") +chat.ui() -## Stream cancellation +@chat.on_user_submit +async def handle_user_input(user_input: str): + await chat.append_message(f"You said: {user_input}") +``` -shinychat supports cancelling in-progress AI response streams. Set `enable_cancel=True` when creating the chat UI to show a stop button during streaming. Users can also press Escape while the chat has focus to cancel. +### Stream cancellation -For cooperative cancellation, [chatlas](https://posit-dev.github.io/chatlas/) provides a `StreamController` that you pass to `stream_async()`. The controller automatically resets itself between streams, so you only need to create it once. +When using the lower-level interface with chatlas, you'll need to wire up cancellation manually. Set `enable_cancel=True` on the chat UI to show a stop button during streaming, then use a `StreamController` to handle the cancellation signal. **Shiny Express** @@ -119,7 +154,7 @@ Key points: - `enable_cancel=True` shows the stop button while a response is streaming. - `StreamController()` is created once and reused — it automatically resets between streams. - Pass the controller to `stream_async(controller=ctrl)` so chatlas can honour the cancellation signal. -- The cancel input fires as `input._cancel` (e.g. `input.chat_cancel`). Observe it with `@reactive.event` to call `ctrl.cancel()`. This is needed in both Express and Core apps. +- The cancel input fires as `input._cancel` (e.g. `input.chat_cancel`). Observe it with `@reactive.event` to call `ctrl.cancel()`. - Partial responses are automatically preserved in chat history by chatlas when a stream is cancelled. ## Learn more diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 34d9e89a..c14e09bb 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,9 +1,13 @@ from ._chat import Chat, chat_greeting, chat_ui +from ._chat_auto import ChatAutoServer, chat_auto_server, chat_auto_ui from ._chat_normalize import message_content, message_content_chunk from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ "Chat", + "ChatAutoServer", + "chat_auto_server", + "chat_auto_ui", "chat_greeting", "chat_ui", "MarkdownStream", diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index c13c0d57..796f49c7 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -81,6 +81,8 @@ from shiny.types import Jsonifiable from shiny.ui.css import CssUnit + from ._chat_auto import ChatAutoServer + else: chatlas = object @@ -1672,12 +1674,6 @@ def enable_bookmarking( 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 ------- : @@ -1690,12 +1686,6 @@ def enable_bookmarking( if session is None or session.is_stub_session(): 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]] @@ -1913,6 +1903,67 @@ def ui( **kwargs, ) + def ui_auto( + self, + client: "chatlas.Chat[Any, Any]", + *, + greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, + bookmark_on: "Optional[Literal['response']]" = "response", + **kwargs: Any, + ) -> "ChatAutoServer": + """ + Create a UI element and wire up a chatlas client for this `Chat`. + + This is a convenience method that combines :meth:`ui` and + :func:`~shinychat.chat_auto_server` into a single call. It creates the + chat UI and registers handlers for streaming, cancellation, and optional + bookmarking. + + Parameters + ---------- + client + A chatlas ``Chat`` instance used to generate responses. + greeting + An optional greeting to display at the top of the chat before any + conversation messages. Can be a markdown string, an + :class:`~htmltools.HTML` object, a :class:`~htmltools.Tag`, a + :class:`~shinychat.ChatGreeting`, or a callable that returns any of + the above. + bookmark_on + When to trigger a bookmark. Passed to + :meth:`~shinychat.Chat.enable_bookmarking`. + kwargs + Additional keyword arguments forwarded to :func:`~shinychat.chat_ui`. + + Returns + ------- + : + A :class:`~shinychat.ChatAutoServer` that exposes the wired-up chat. + """ + from ._chat_auto import chat_auto_server + + if callable(greeting) and not isinstance( + greeting, (str, HTML, Tag, TagList, ChatGreeting) + ): + ui_greeting = None + else: + ui_greeting = greeting # type: ignore[assignment] + tag = chat_ui( + id=self.id, + enable_cancel=True, + greeting=ui_greeting, + **kwargs, + ) + + result = chat_auto_server( + self.id, + client, + greeting=greeting, + bookmark_on=bookmark_on, + ) + result._tag = tag + return result + def enable_bookmarking( self, client: "ClientWithState | chatlas.Chat[Any, Any]", @@ -1947,11 +1998,6 @@ def enable_bookmarking( 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 ------- : diff --git a/pkg-py/src/shinychat/_chat_auto.py b/pkg-py/src/shinychat/_chat_auto.py new file mode 100644 index 00000000..2ae65553 --- /dev/null +++ b/pkg-py/src/shinychat/_chat_auto.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import copy +import inspect +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Literal, + Optional, +) + +from htmltools import HTML, Tag, TagList + +from ._chat import Chat, chat_ui +from ._chat_bookmark import CancelCallback +from ._chat_types import ChatGreeting, ChatMessageDict + +if TYPE_CHECKING: + import chatlas + from htmltools import Tagified + from shiny.session import Session + +__all__ = ( + "ChatAutoServer", + "chat_auto_ui", + "chat_auto_server", +) + + +class ChatAutoServer: + """ + A convenience wrapper that wires a :class:`~shinychat.Chat` instance to a chatlas + client. + + This class is normally created via :func:`~shinychat.chat_auto_server` rather than + instantiated directly. It exposes the same surface as :class:`~shinychat.Chat` for + reading messages and updating the UI, while adding higher-level helpers for + swapping the underlying chatlas client and clearing the conversation. + + Parameters + ---------- + chat + The underlying :class:`~shinychat.Chat` (or :class:`~shinychat.ChatExpress`) + instance. + client + A chatlas ``Chat`` client used for response generation. + tag + Optional tag returned by :meth:`tagify` (used in Express). + bookmark_on + Passed through to :meth:`~shinychat.Chat.enable_bookmarking`. + """ + + def __init__( + self, + *, + chat: Chat, + client: "chatlas.Chat[Any, Any]", + tag: Tag | None = None, + bookmark_on: Optional[Literal["response"]] = "response", + ) -> None: + self._chat = chat + self._client = client + self._tag = tag + self._bookmark_on: Optional[Literal["response"]] = bookmark_on + self._pending_swap: "tuple[chatlas.Chat[Any, Any], bool] | None" = None + self._cancel_bookmarking: CancelCallback | None = None + + # ------------------------------------------------------------------ + # Express rendering + # ------------------------------------------------------------------ + + def tagify(self) -> "Tagified": + """Return the tag for Express rendering. + + Raises + ------ + RuntimeError + If no tag was provided at construction time (i.e. Core mode). + """ + if self._tag is None: + raise RuntimeError( + "tagify() is only available when ChatAutoServer was created " + "via Chat.ui_auto() in Express mode." + ) + return self._tag.tagify() + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def chat(self) -> Chat: + """The underlying :class:`~shinychat.Chat` instance.""" + return self._chat + + @property + def client(self) -> "chatlas.Chat[Any, Any]": + """The current chatlas client. + + Assigning to this property is equivalent to calling + :meth:`set_client` with ``sync=True``. + """ + return self._client + + @client.setter + def client(self, new_client: "chatlas.Chat[Any, Any]") -> None: + self.set_client(new_client, sync=True) + + # ------------------------------------------------------------------ + # Module-specific + # ------------------------------------------------------------------ + + def set_client( + self, + new_client: "chatlas.Chat[Any, Any]", + *, + sync: bool = True, + ) -> None: + """Replace the chatlas client. + + Parameters + ---------- + new_client + The new chatlas ``Chat`` client to use. + sync + If ``True`` (the default), copy the current client's turns, system + prompt, and tools to the new client before swapping. + """ + if self._chat.latest_message_stream.status() == "running": + self._pending_swap = (new_client, sync) + else: + self._swap_client(new_client, sync=sync) + + def _swap_client( + self, + new_client: "chatlas.Chat[Any, Any]", + *, + sync: bool, + ) -> None: + if sync: + old = self._client + new_client.set_turns(old.get_turns()) + if old.system_prompt is not None: + new_client.system_prompt = old.system_prompt + # chatlas get_tools() returns Tool|ToolBuiltIn but set_tools() doesn't accept ToolBuiltIn + new_client.set_tools(old.get_tools()) # type: ignore[arg-type] + + self._client = new_client + + # Re-register bookmarking with the new client + if self._cancel_bookmarking is not None: + self._cancel_bookmarking() + self._cancel_bookmarking = None + cancel = self._chat.enable_bookmarking( + new_client, bookmark_on=self._bookmark_on + ) + self._cancel_bookmarking = cancel + + async def clear( + self, + *, + messages: list[ChatMessageDict] | None = None, + greeting: bool = False, + client_history: Literal["clear", "set", "append", "keep"] = "clear", + ) -> None: + """Clear the chat UI and manage client history. + + Parameters + ---------- + messages + Optional list of message dicts to display in the chat after clearing. + greeting + If ``True``, also clear the greeting (triggers re-request). + client_history + How to handle the chatlas client's turn history: + + - ``"clear"``: wipe turns. + - ``"set"``: replace with ``messages`` (converted to turns). + - ``"append"``: add ``messages`` to existing turns. + - ``"keep"``: leave turns untouched. + """ + if messages is None and client_history in ("set", "append"): + raise ValueError( + f"client_history='{client_history}' requires `messages` to be provided." + ) + + await self._chat.clear_messages(greeting=greeting) + + if messages is not None: + for msg in messages: + await self._chat.append_message(msg) + + if client_history == "clear": + self._client.set_turns([]) + elif client_history == "set": + assert messages is not None + turns = messages_to_turns(messages) + self._client.set_turns(turns) + elif client_history == "append": + assert messages is not None + existing = self._client.get_turns() + extra = messages_to_turns(messages) + self._client.set_turns(existing + extra) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def chat_auto_ui( + id: str, + **kwargs: Any, +) -> Tag: + """UI container for an auto-wired chat component (Shiny Core). + + A thin wrapper around :func:`~shinychat.chat_ui` with ``enable_cancel=True`` + pre-set. All keyword arguments are forwarded to :func:`~shinychat.chat_ui`. + + Parameters + ---------- + id + A unique identifier for the chat UI. + kwargs + Keyword arguments forwarded to :func:`~shinychat.chat_ui`. + """ + return chat_ui( + id, + enable_cancel=True, + **kwargs, + ) + + +def chat_auto_server( + id: str, + client: "chatlas.Chat[Any, Any]", + *, + greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, + bookmark_on: Optional[Literal["response"]] = "response", +) -> ChatAutoServer: + """Wire up a chatlas client to a chat UI with streaming, cancellation, and bookmarking. + + Creates a :class:`~shinychat.Chat`, registers handlers for user input, + cancellation, and optional bookmarking, then returns a + :class:`~shinychat.ChatAutoServer` that wraps it all. + + Parameters + ---------- + id + The chat component ID (must match the corresponding ``chat_auto_ui(id)`` call). + client + A chatlas ``Chat`` instance used to generate responses. + greeting + Optional greeting content. Can be: + + * A static string/HTML/tag — displayed when the chat first appears. + * A callable — called (with an optional ``client`` keyword argument) each + time a greeting is requested; the return value is passed to + :meth:`~shinychat.Chat.set_greeting`. + bookmark_on + When to trigger a bookmark. Passed to + :meth:`~shinychat.Chat.enable_bookmarking`. + + Returns + ------- + : + A :class:`~shinychat.ChatAutoServer` that exposes the wired-up chat. + """ + from chatlas import StreamController + from shiny import reactive + from shiny.session import require_active_session + + session = require_active_session(None) + chat = Chat(id) + + controller = StreamController() + + result = ChatAutoServer( + chat=chat, + client=client, + bookmark_on=bookmark_on, + ) + + @chat.on_user_submit + async def _on_user_submit(user_input: str) -> None: + response = await result.client.stream_async( + user_input, + content="all", + controller=controller, + ) + await chat.append_message_stream(response) + + cancel_input_id = f"{id}_cancel" + + @reactive.effect + @reactive.event(session.input[cancel_input_id]) + async def _on_cancel() -> None: + controller.cancel() + + @reactive.effect + async def _on_stream_complete() -> None: + status = chat.latest_message_stream.status() + if status == "running": + return + + swap = result._pending_swap + if swap is None: + return + result._pending_swap = None + new_client, sync = swap + result._swap_client(new_client, sync=sync) + + cancel_bm = chat.enable_bookmarking(client, bookmark_on=bookmark_on) + result._cancel_bookmarking = cancel_bm + + setup_greeting(chat, result, greeting, session) + + return result + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +def messages_to_turns( + messages: list[ChatMessageDict], +) -> list["chatlas.Turn"]: + """Convert a list of ``ChatMessageDict`` objects to chatlas ``Turn`` objects.""" + from chatlas import Turn + + turns: list[Turn] = [] + for msg in messages: + role_raw = msg.get("role", "assistant") + content = msg.get("content", "") + if role_raw == "user": + role: Literal["user", "assistant"] = "user" + else: + role = "assistant" + turns.append(Turn(content, role=role)) + return turns + + +def setup_greeting( + chat: Chat, + result: "ChatAutoServer", + greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None", + session: "Session", +) -> None: + """Wire up greeting handling for ``chat_auto_server``.""" + from shiny import reactive + + if greeting is None: + return + + greeting_input_id = f"{chat.id}_greeting_requested" + + if isinstance(greeting, (str, HTML, Tag, TagList, ChatGreeting)): + static_greeting = greeting + + @reactive.effect + @reactive.event(session.input[greeting_input_id]) + async def _handle_static_greeting() -> None: + await chat.set_greeting(static_greeting) + + elif callable(greeting): + fn = greeting + fn_params = inspect.signature(fn).parameters + has_client_param = "client" in fn_params + + @reactive.effect + @reactive.event(session.input[greeting_input_id]) + async def _handle_greeting() -> None: + if has_client_param: + greeting_client = copy.deepcopy(result.client) + greeting_client.set_turns([]) + g = fn(client=greeting_client) + else: + g = fn() + if inspect.isawaitable(g): + g = await g + await chat.set_greeting(g) diff --git a/pkg-py/src/shinychat/types/__init__.py b/pkg-py/src/shinychat/types/__init__.py index c837edb1..2b4b1ac9 100644 --- a/pkg-py/src/shinychat/types/__init__.py +++ b/pkg-py/src/shinychat/types/__init__.py @@ -1,4 +1,5 @@ from .._chat import ChatMessage, ChatMessageDict +from .._chat_auto import ChatAutoServer from .._chat_types import ChatGreeting try: @@ -17,6 +18,7 @@ def __init__(self, *args, **kwargs): __all__ = [ + "ChatAutoServer", "ChatGreeting", "ChatMessage", "ChatMessageDict", diff --git a/pkg-py/tests/playwright/chat/auto_cancel_core/app.py b/pkg-py/tests/playwright/chat/auto_cancel_core/app.py new file mode 100644 index 00000000..6f61d5d6 --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_cancel_core/app.py @@ -0,0 +1,84 @@ +import asyncio + +import chatlas +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +from shinychat import chat_auto_server, chat_auto_ui + + +class ObservableStreamController(chatlas.StreamController): + cancel_requested: reactive.Value[bool] | None = None + + def cancel(self, reason: str = "cancelled") -> None: + super().cancel(reason=reason) + if self.cancel_requested is not None: + self.cancel_requested.set(True) + + +chatlas.StreamController = ObservableStreamController + + +class SlowChatClient: + def __init__(self) -> None: + self._turns: list[object] = [] + self.system_prompt: str | None = None + self._tools: list[object] = [] + + def get_turns(self) -> list[object]: + return list(self._turns) + + def set_turns(self, turns: list[object]) -> None: + self._turns = list(turns) + + def get_tools(self) -> list[object]: + return list(self._tools) + + def set_tools(self, tools: list[object]) -> None: + self._tools = list(tools) + + async def stream_async( + self, + *args: object, + content: str = "text", + controller: chatlas.StreamController | None = None, + ): + del content + user_input = str(args[0]) if args else "" + self._turns.append({"role": "user", "content": user_input}) + + async def _gen(): + for chunk in ("alpha ", "beta ", "gamma ", "delta "): + if controller is not None and controller.cancelled: + break + await asyncio.sleep(0.2) + yield chunk + + return _gen() + + async def get_state(self): + return {"version": 1, "turns": self._turns} + + async def set_state(self, state: object) -> None: + assert isinstance(state, dict) + self._turns = state.get("turns", []) + + +app_ui = ui.page_fillable( + chat_auto_ui("chat"), + ui.output_code("cancel_requested"), +) + + +def server(input: Inputs, output: Outputs, session: Session) -> None: + del input, output, session + cancel_requested_value = reactive.Value(False) + ObservableStreamController.cancel_requested = cancel_requested_value + client = SlowChatClient() + + chat = chat_auto_server("chat", client) + + @render.code + def cancel_requested() -> str: + return str(cancel_requested_value()) + +app = App(app_ui, server) diff --git a/pkg-py/tests/playwright/chat/auto_cancel_core/test_chat_auto_cancel_core.py b/pkg-py/tests/playwright/chat/auto_cancel_core/test_chat_auto_cancel_core.py new file mode 100644 index 00000000..68a3eaad --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_cancel_core/test_chat_auto_cancel_core.py @@ -0,0 +1,24 @@ +from playwright.sync_api import Page, expect +from shiny.playwright import controller +from shiny.run import ShinyAppProc +from shinychat.playwright import ChatController + + +def test_auto_chat_cancel_uses_stream_controller( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chat") + cancel_requested = controller.OutputCode(page, "cancel_requested") + + expect(chat.loc).to_be_visible(timeout=30_000) + + chat.set_user_input("cancel this response") + chat.send_user_input(method="enter") + + cancel_button = chat.loc_input_container.locator(".shiny-chat-btn-cancel") + expect(cancel_button).to_be_visible(timeout=30_000) + cancel_button.click() + + cancel_requested.expect_value("True", timeout=30_000) diff --git a/pkg-py/tests/playwright/chat/auto_core/app.py b/pkg-py/tests/playwright/chat/auto_core/app.py new file mode 100644 index 00000000..a8df4b67 --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_core/app.py @@ -0,0 +1,66 @@ +from shiny import App, Inputs, Outputs, Session, render, ui +from shinychat import chat_auto_server, chat_auto_ui + + +class MockChatClient: + """A mock chatlas-like client for testing.""" + + def __init__(self) -> None: + self._turns: list[object] = [] + self.system_prompt: str | None = None + self._tools: list[object] = [] + + def get_turns(self) -> list[object]: + return list(self._turns) + + def set_turns(self, turns: list[object]) -> None: + self._turns = list(turns) + + def get_tools(self) -> list[object]: + return list(self._tools) + + def set_tools(self, tools: list[object]) -> None: + self._tools = list(tools) + + async def stream_async( + self, + *args: object, + content: str = "text", + controller: object | None = None, + ): + del controller + user_input = str(args[0]) if args else "" + self._turns.append({"role": "user", "content": user_input}) + reply = f"You said: {user_input}" + self._turns.append({"role": "assistant", "content": reply}) + + async def _gen(): + for word in reply.split(" "): + yield word + " " + + return _gen() + + async def get_state(self): + return {"version": 1, "turns": self._turns} + + async def set_state(self, state: object) -> None: + assert isinstance(state, dict) + self._turns = state.get("turns", []) + + +app_ui = ui.page_fillable( + chat_auto_ui("chat"), + ui.output_code("message_state"), +) + + +def server(input: Inputs, output: Outputs, session: Session) -> None: + client = MockChatClient() + chat = chat_auto_server("chat", client, greeting="Welcome!") + + @render.code + def message_state(): + return str(chat.chat.messages()) + + +app = App(app_ui, server) diff --git a/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py b/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py new file mode 100644 index 00000000..68802f91 --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py @@ -0,0 +1,48 @@ +from playwright.sync_api import Page, expect +from shiny.playwright import controller +from shiny.run import ShinyAppProc +from shinychat.playwright import ChatController + + +def test_app_loads_and_chat_is_visible(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + +def test_greeting_is_displayed(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + greeting = chat.loc.locator(".shiny-chat-greeting") + expect(greeting).to_be_visible(timeout=10_000) + expect(greeting).to_contain_text("Welcome!", timeout=10_000) + + +def test_sending_message_gets_response(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + user_message = "Hello there" + chat.set_user_input(user_message) + chat.send_user_input(method="enter") + chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) +def test_messages_state_updated_after_exchange( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + user_message = "Hello" + chat.set_user_input(user_message) + chat.send_user_input(method="enter") + chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) + + message_state = controller.OutputCode(page, "message_state") + # Verify that both user and assistant messages appear in state + message_state.expect.to_contain_text("user", timeout=10_000) + message_state.expect.to_contain_text("assistant", timeout=10_000) + message_state.expect.to_contain_text(user_message, timeout=10_000) diff --git a/pkg-py/tests/playwright/chat/auto_express/app.py b/pkg-py/tests/playwright/chat/auto_express/app.py new file mode 100644 index 00000000..f3fa4665 --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_express/app.py @@ -0,0 +1,63 @@ +from shiny.express import render +from shinychat.express import Chat + + +class MockChatClient: + """A mock chatlas-like client for testing.""" + + def __init__(self) -> None: + self._turns: list[object] = [] + self.system_prompt: str | None = None + self._tools: list[object] = [] + + def get_turns(self) -> list[object]: + return list(self._turns) + + def set_turns(self, turns: list[object]) -> None: + self._turns = list(turns) + + def get_tools(self) -> list[object]: + return list(self._tools) + + def set_tools(self, tools: list[object]) -> None: + self._tools = list(tools) + + async def stream_async( + self, + *args: object, + content: str = "text", + controller: object | None = None, + ): + del controller + user_input = str(args[0]) if args else "" + self._turns.append({"role": "user", "content": user_input}) + reply = f"You said: {user_input}" + self._turns.append({"role": "assistant", "content": reply}) + + async def _gen(): + for word in reply.split(" "): + yield word + " " + + return _gen() + + async def get_state(self): + return {"version": 1, "turns": self._turns} + + async def set_state(self, state: object) -> None: + assert isinstance(state, dict) + self._turns = state.get("turns", []) + + +client = MockChatClient() + +chat = Chat(id="chat") +auto = chat.ui_auto(client, greeting="Welcome to the test!") +auto + + +"auto.messages():" + + +@render.code +def message_state(): + return str(auto.chat.messages()) diff --git a/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py b/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py new file mode 100644 index 00000000..895a50f4 --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py @@ -0,0 +1,50 @@ +from playwright.sync_api import Page, expect +from shiny.playwright import controller +from shiny.run import ShinyAppProc +from shinychat.playwright import ChatController + + +def _loc_greeting(chat: ChatController): + return chat.loc.locator(".shiny-chat-greeting") + + +def test_app_loads_and_chat_is_visible(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + +def test_greeting_is_displayed(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + greeting = _loc_greeting(chat) + expect(greeting).to_be_visible(timeout=10_000) + expect(greeting).to_contain_text("Welcome to the test!", timeout=10_000) + + +def test_send_message_gets_response(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + user_message = "Hello from the test" + chat.set_user_input(user_message) + chat.send_user_input(method="enter") + chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) +def test_messages_reflect_conversation(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + message_state = controller.OutputCode(page, "message_state") + + user_message = "Tell me something" + chat.set_user_input(user_message) + chat.send_user_input(method="enter") + chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) + + # messages() should contain both user and assistant turns + message_state.expect.to_contain_text(user_message, timeout=10_000) + message_state.expect.to_contain_text(f"You said: {user_message}", timeout=10_000) diff --git a/pkg-py/tests/playwright/chat/bookmark_no_store/app.py b/pkg-py/tests/playwright/chat/bookmark_no_store/app.py new file mode 100644 index 00000000..6a983209 --- /dev/null +++ b/pkg-py/tests/playwright/chat/bookmark_no_store/app.py @@ -0,0 +1,39 @@ +from shiny.express import render +from shiny.types import Jsonifiable +from shinychat.express import Chat + + +class MockClient: + def __init__(self) -> None: + self.turns: list[object] = [] + + async def get_state(self) -> Jsonifiable: + return {"version": 1, "turns": self.turns} + + async def set_state(self, state: Jsonifiable) -> None: + assert isinstance(state, dict) + self.turns = state.get("turns", []) # type: ignore + + +client = MockClient() + +chat = Chat(id="chat") +chat.ui() +# No bookmark_store= set — this should NOT raise +chat.enable_bookmarking(client) + + +@chat.on_user_submit +async def handle_user_input(user_input: str): + client.turns.append({"role": "user", "content": user_input}) + reply = f"You said: {user_input}" + client.turns.append({"role": "assistant", "content": reply}) + await chat.append_message(reply) + + +"chat.messages():" + + +@render.code +def message_state(): + return str(chat.messages()) diff --git a/pkg-py/tests/playwright/chat/bookmark_no_store/test_bookmark_no_store.py b/pkg-py/tests/playwright/chat/bookmark_no_store/test_bookmark_no_store.py new file mode 100644 index 00000000..80fa51c7 --- /dev/null +++ b/pkg-py/tests/playwright/chat/bookmark_no_store/test_bookmark_no_store.py @@ -0,0 +1,19 @@ +from playwright.sync_api import Page, expect +from shiny.run import ShinyAppProc +from shinychat.playwright import ChatController + + +def test_no_error_without_bookmark_store( + page: Page, local_app: ShinyAppProc +) -> None: + """enable_bookmarking() should not raise when no bookmark store is configured.""" + page.goto(local_app.url) + + chat = ChatController(page, "chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + # App loaded without crashing — the main assertion + # Verify chat works normally + chat.set_user_input("hello") + chat.send_user_input(method="enter") + chat.expect_latest_message("You said: hello", timeout=30_000) diff --git a/pkg-py/tests/pytest/test_chat_auto.py b/pkg-py/tests/pytest/test_chat_auto.py new file mode 100644 index 00000000..e0a75e80 --- /dev/null +++ b/pkg-py/tests/pytest/test_chat_auto.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import pytest +from typing import cast + +from htmltools import Tag, tags +from shiny import Session +from shiny.module import ResolvedId +from shiny.session import session_context +from shinychat import Chat, ChatAutoServer, chat_auto_ui +from shinychat._chat_auto import messages_to_turns + + +class _MockSession: + ns: ResolvedId = ResolvedId("") + app: object = None + id: str = "mock-session" + + def on_ended(self, callback: object) -> None: + pass + + def on_destroy(self, callback: object) -> None: + pass + + def _increment_busy_count(self) -> None: + pass + + def is_stub_session(self) -> bool: + return True + + +test_session = cast(Session, _MockSession()) + + +class MockClient: + def __init__(self) -> None: + self._turns: list[object] = [] + self.system_prompt: str | None = None + self._tools: list[object] = [] + + def get_turns(self) -> list[object]: + return list(self._turns) + + def set_turns(self, turns: list[object]) -> None: + self._turns = list(turns) + + def get_tools(self) -> list[object]: + return list(self._tools) + + def set_tools(self, tools: list[object]) -> None: + self._tools = list(tools) + + +def make_auto( + client: MockClient | None = None, +) -> tuple[ChatAutoServer, MockClient]: + client = client or MockClient() + with session_context(test_session): + chat = Chat("test") + auto = ChatAutoServer(chat=chat, client=client) + return auto, client + + +# --------------------------------------------------------------------------- +# chat_auto_ui() +# --------------------------------------------------------------------------- + + +def test_chat_auto_ui_sets_id_and_enables_cancel(): + with session_context(test_session): + result = chat_auto_ui("myid") + assert isinstance(result, Tag) + html = result.get_html_string() + assert 'id="myid"' in html + assert "enable-cancel" in html + + +def test_chat_auto_ui_forwards_kwargs(): + with session_context(test_session): + result = chat_auto_ui( + "myid", + placeholder="Ask me anything...", + height="400px", + greeting="Hello there!", + footer=tags.div("my footer"), + icon_assistant=tags.span("bot"), + ) + html = result.get_html_string() + assert "Ask me anything..." in html + assert "400px" in html + assert "Hello there!" in html + assert "my footer" in html + assert "bot" in html + + +# --------------------------------------------------------------------------- +# ChatAutoServer properties +# --------------------------------------------------------------------------- + + +def test_chat_and_client_properties(): + client = MockClient() + auto, _ = make_auto(client) + assert auto.chat is auto._chat + assert auto.client is client + + +# --------------------------------------------------------------------------- +# _swap_client +# --------------------------------------------------------------------------- + + +def test_swap_client_sync_copies_state(): + auto, old = make_auto() + old._turns = ["t1", "t2"] + old.system_prompt = "Be helpful" + old._tools = ["tool_a"] + + new = MockClient() + with session_context(test_session): + auto._swap_client(new, sync=True) + + assert auto.client is new + assert new._turns == ["t1", "t2"] + assert new.system_prompt == "Be helpful" + assert new._tools == ["tool_a"] + + +def test_swap_client_no_sync_skips_copy(): + auto, old = make_auto() + old._turns = ["t1"] + old.system_prompt = "Be helpful" + old._tools = ["tool_a"] + + new = MockClient() + with session_context(test_session): + auto._swap_client(new, sync=False) + + assert auto.client is new + assert new._turns == [] + assert new.system_prompt is None + assert new._tools == [] + + +def test_swap_client_skips_none_system_prompt(): + auto, old = make_auto() + old.system_prompt = None + + new = MockClient() + new.system_prompt = "Keep me" + with session_context(test_session): + auto._swap_client(new, sync=True) + + assert new.system_prompt == "Keep me" + + +# --------------------------------------------------------------------------- +# clear() — validation +# --------------------------------------------------------------------------- + + +def test_clear_rejects_set_without_messages(): + import asyncio + + auto, _ = make_auto() + loop = asyncio.new_event_loop() + try: + with pytest.raises(ValueError, match="client_history='set'"): + loop.run_until_complete(auto.clear(client_history="set")) + finally: + loop.close() + + +def test_clear_rejects_append_without_messages(): + import asyncio + + auto, _ = make_auto() + loop = asyncio.new_event_loop() + try: + with pytest.raises(ValueError, match="client_history='append'"): + loop.run_until_complete(auto.clear(client_history="append")) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# messages_to_turns +# --------------------------------------------------------------------------- + + +def test_messages_to_turns_basic(): + msgs = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + {"role": "user", "content": "bye"}, + ] + turns = messages_to_turns(msgs) + + assert len(turns) == 3 + assert turns[0].role == "user" + assert turns[0].text == "hello" + assert turns[1].role == "assistant" + assert turns[1].text == "hi there" + assert turns[2].role == "user" + assert turns[2].text == "bye" + + +def test_messages_to_turns_empty(): + assert messages_to_turns([]) == [] + + +def test_messages_to_turns_defaults_to_assistant(): + turns = messages_to_turns([{"content": "no role"}]) + assert turns[0].role == "assistant" + assert turns[0].text == "no role" + + +# --------------------------------------------------------------------------- +# tagify +# --------------------------------------------------------------------------- + + +def test_tagify_raises_without_tag(): + auto, _ = make_auto() + with pytest.raises(RuntimeError, match="tagify"): + auto.tagify() + + +def test_tagify_delegates_to_tag(): + auto, _ = make_auto() + with session_context(test_session): + tag = chat_auto_ui("test") + auto._tag = tag + result = auto.tagify() + assert result is not None + + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- + + +def test_public_exports(): + from shinychat import ChatAutoServer, chat_auto_server, chat_auto_ui + from shinychat.types import ChatAutoServer as TypesChatAutoServer + + assert callable(chat_auto_ui) + assert callable(chat_auto_server) + assert ChatAutoServer is TypesChatAutoServer diff --git a/pkg-r/R/chat_restore.R b/pkg-r/R/chat_restore.R index 00c9d397..5a1043b5 100644 --- a/pkg-r/R/chat_restore.R +++ b/pkg-r/R/chat_restore.R @@ -89,9 +89,6 @@ chat_restore <- function( ) } - # Verify bookmark store is not disabled. Bookmark options: "disable", "url", "server" - bookmark_store <- shiny::getShinyOption("bookmarkStore", "disable") - # Exclude works with bookmark names excluded_names <- session$getBookmarkExclude() id_user_input <- paste0(id, "_user_input") From a9d70b9a8edce30c18688cfb5933b798e3fc7705 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 26 May 2026 20:24:37 -0500 Subject: [PATCH 2/4] refactor(py): replace ChatAutoServer API with client= parameter on Chat Instead of a separate ChatAutoServer/chat_auto_server/chat_auto_ui API surface, users now pass client= directly to Chat(). When provided, streaming, cancellation, bookmarking, and greeting handling are wired up automatically. The client-specific surface lives on a ChatClient wrapper at chat.client, with .value (raw chatlas object), .set() for swapping models mid-session, and .clear() for resetting conversation history. Express mode auto-defaults enable_cancel=True when a client is set. - Add _chat_client.py with ChatClient class and helpers - Add client=, greeting=, bookmark_on= params to Chat.__init__ - Add _setup_client() for reactive wiring - Remove _chat_auto.py, ChatAutoServer, chat_auto_server, chat_auto_ui - Remove ChatExpress.ui_auto() method - Remove bookmark-store raise from enable_bookmarking() - Update exports, tests, docs, and changelog --- pkg-py/CHANGELOG.md | 2 +- pkg-py/docs/_quarto.yml | 13 +- pkg-py/docs/index.qmd | 43 +- pkg-py/src/shinychat/__init__.py | 6 +- pkg-py/src/shinychat/_chat.py | 152 ++++--- pkg-py/src/shinychat/_chat_auto.py | 383 ------------------ pkg-py/src/shinychat/_chat_client.py | 245 +++++++++++ pkg-py/src/shinychat/types/__init__.py | 4 +- .../playwright/chat/auto_cancel_core/app.py | 8 +- pkg-py/tests/playwright/chat/auto_core/app.py | 8 +- .../chat/auto_core/test_chat_auto_core.py | 3 +- .../tests/playwright/chat/auto_express/app.py | 9 +- .../auto_express/test_chat_auto_express.py | 3 +- .../playwright/chat/bookmark_no_store/app.py | 2 +- pkg-py/tests/pytest/test_chat_auto.py | 291 +++++++------ 15 files changed, 522 insertions(+), 650 deletions(-) delete mode 100644 pkg-py/src/shinychat/_chat_auto.py create mode 100644 pkg-py/src/shinychat/_chat_client.py diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index c771e47d..0bb15621 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features -* Added `chat_auto_ui()`, `chat_auto_server()`, and `Chat.ui_auto()` — a batteries-included API that wires streaming, cancellation, and bookmarking to a chatlas client automatically. Hand it a chatlas client and get a complete chat interface with no manual plumbing. The returned `ChatAutoServer` lets you swap models mid-session (`.client`), reset the conversation (`.clear()`), and access the underlying `Chat` for lower-level operations. +* `Chat()` now accepts an optional `client=` parameter. When provided, streaming, cancellation, bookmarking, and greeting handling are wired up automatically — no manual plumbing required. The `chat.client` property exposes a `ChatClient` wrapper with `.value` (the raw chatlas client), `.set()` for swapping models mid-session, and `.clear()` for resetting the conversation with flexible history management. * Added `footer` parameter to `chat_ui()` and `Chat.ui()` for displaying arbitrary HTML content below the chat input. Useful for disclaimers, attribution, or interactive toolbars. Styled with sensible defaults and customizable via `--shiny-chat-footer-font-size` and `--shiny-chat-footer-color` CSS custom properties. (#224) diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index 6a9f972c..d546d595 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -69,20 +69,9 @@ quartodoc: include_functions: true contents: - Chat + - ChatClient - chat_ui - chat_greeting - - title: Auto-wired Chat - options: - signature_name: relative - include_imports: false - include_inherited: false - include_attributes: true - include_classes: true - include_functions: true - contents: - - chat_auto_ui - - chat_auto_server - - ChatAutoServer - title: Shiny Express options: signature_name: relative diff --git a/pkg-py/docs/index.qmd b/pkg-py/docs/index.qmd index d413d7bb..ee28464c 100644 --- a/pkg-py/docs/index.qmd +++ b/pkg-py/docs/index.qmd @@ -7,7 +7,7 @@ pagetitle: "Get Started" -**shinychat** provides a [Shiny](https://shiny.posit.co/py) toolkit for building generative AI chatbots. It works best with [chatlas](https://posit-dev.github.io/chatlas/), but also supports other LLM frameworks such as LangChain, Pydantic AI, and more. +**shinychat** provides a [Shiny](https://shiny.posit.co/py) toolkit for building generative AI applications like chatbots and streaming content. It works best with [chatlas](https://posit-dev.github.io/chatlas/), but also works great with other LLM frameworks such as LangChain, Pydantic AI, and more. ## Installation @@ -31,7 +31,7 @@ uv pip install git+https://github.com/posit-dev/shinychat.git ## Quick start -The fastest way to build a chatbot with shinychat is to hand a [chatlas](https://posit-dev.github.io/chatlas/) client to `ui_auto()` (Express) or `chat_auto_ui()`/`chat_auto_server()` (Core). This gives you streaming, cancellation, bookmarking, and more out of the box — no manual wiring required. +The fastest way to build a chatbot with shinychat is to pass a [chatlas](https://posit-dev.github.io/chatlas/) client to `Chat(client=)`. This gives you streaming, cancellation, bookmarking, and more out of the box — no manual wiring required. **Shiny Express** @@ -41,8 +41,8 @@ from shiny.express import app_opts, ui from shinychat.express import Chat client = ChatAnthropic(system_prompt="You are a helpful assistant.") -chat = Chat(id="chat") -chat.ui_auto(client) +chat = Chat(id="chat", client=client) +chat.ui() ui.page_opts(fillable=True) app_opts(bookmark_store="url") @@ -53,47 +53,24 @@ app_opts(bookmark_store="url") ```{.python file="app.py"} from shiny import App, ui from chatlas import ChatAnthropic -from shinychat import chat_auto_ui, chat_auto_server +from shinychat import Chat, chat_ui app_ui = ui.page_fillable( - chat_auto_ui("chat"), + chat_ui("chat", enable_cancel=True), ) def server(input, output, session): client = ChatAnthropic(system_prompt="You are a helpful assistant.") - chat = chat_auto_server("chat", client=client) + chat = Chat("chat", client=client) app = App(app_ui, server, bookmark_store="url") ``` -To run the app, execute the following command in your terminal (or via the [Shiny extension](https://marketplace.visualstudio.com/items?itemName=Posit.shiny)): - -```bash -uv shiny run --reload app.py -``` - -The returned `ChatAutoServer` object provides a read/write `.client` property for swapping models mid-session and `.clear()` for resetting the conversation. For lower-level chat methods such as appending messages, updating the input, or inspecting stream state, use `chat.chat.()` on the underlying `Chat` instance. See the [API reference](api/index.qmd) for the full interface. +The `chat.client` property provides a `.set()` method for swapping models mid-session and `.clear()` for resetting the conversation. For lower-level chat methods such as appending messages, updating the input, or inspecting stream state, call them directly on `chat`. See the [API reference](api/index.qmd) for the full interface. ## Lower-level interface -When you need full control over the server-side logic — or you're using an LLM framework other than chatlas — use `Chat`/`chat_ui` directly. This means wiring up streaming, cancellation, and bookmarking yourself, but gives you complete flexibility over how messages are generated and displayed. - -Here's a minimal echo example: - -```{.python file="app.py"} -from shinychat.express import Chat - -chat = Chat(id="chat") -chat.ui() - -@chat.on_user_submit -async def handle_user_input(user_input: str): - await chat.append_message(f"You said: {user_input}") -``` - -### Stream cancellation - -When using the lower-level interface with chatlas, you'll need to wire up cancellation manually. Set `enable_cancel=True` on the chat UI to show a stop button during streaming, then use a `StreamController` to handle the cancellation signal. +When you need full control over the server-side logic — or you're using an LLM framework other than chatlas — omit the `client=` argument and wire things up manually. This means handling streaming, cancellation, and bookmarking yourself, but gives you complete flexibility over how messages are generated and displayed. **Shiny Express** @@ -154,7 +131,7 @@ Key points: - `enable_cancel=True` shows the stop button while a response is streaming. - `StreamController()` is created once and reused — it automatically resets between streams. - Pass the controller to `stream_async(controller=ctrl)` so chatlas can honour the cancellation signal. -- The cancel input fires as `input._cancel` (e.g. `input.chat_cancel`). Observe it with `@reactive.event` to call `ctrl.cancel()`. +- The cancel input fires as `input._cancel` (e.g. `input.chat_cancel`). Observe it with `@reactive.event` to call `ctrl.cancel()`. This is needed in both Express and Core apps. - Partial responses are automatically preserved in chat history by chatlas when a stream is cancelled. ## Learn more diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index c14e09bb..df5f0f47 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,13 +1,11 @@ from ._chat import Chat, chat_greeting, chat_ui -from ._chat_auto import ChatAutoServer, chat_auto_server, chat_auto_ui +from ._chat_client import ChatClient from ._chat_normalize import message_content, message_content_chunk from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ "Chat", - "ChatAutoServer", - "chat_auto_server", - "chat_auto_ui", + "ChatClient", "chat_greeting", "chat_ui", "MarkdownStream", diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index 796f49c7..cc532823 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -81,7 +81,7 @@ from shiny.types import Jsonifiable from shiny.ui.css import CssUnit - from ._chat_auto import ChatAutoServer + from ._chat_client import ChatClient else: @@ -243,6 +243,9 @@ def __init__( self, id: str, *, + client: "chatlas.Chat[Any, Any] | None" = None, + greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, + bookmark_on: "Optional[Literal['response']]" = "response", messages: Sequence[Any] = (), on_error: Literal["auto", "actual", "sanitize", "unhandled"] = "auto", tokenizer: TokenEncoding | None = None, @@ -370,6 +373,68 @@ async def _on_user_input(): instance.destroy() CHAT_INSTANCES[instance_id] = self + self.client: "ChatClient | None" = None + if client is not None: + self._setup_client(client, greeting=greeting, bookmark_on=bookmark_on) + + def _setup_client( + self, + client: "chatlas.Chat[Any, Any]", + *, + greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, + bookmark_on: "Optional[Literal['response']]" = "response", + ) -> None: + from chatlas import StreamController + from shiny import reactive + + from ._chat_client import ChatClient, setup_greeting + + chat_client = ChatClient( + chat=self, + client=client, + bookmark_on=bookmark_on, + ) + self.client = chat_client + + controller = StreamController() + + @self.on_user_submit + async def _on_user_submit(user_input: str) -> None: + response = await chat_client.value.stream_async( + user_input, + content="all", + controller=controller, + ) + await self.append_message_stream(response) + + cancel_input_id = f"{self.id}_cancel" + + @reactive.effect + @reactive.event(self._session.input[cancel_input_id]) + async def _on_cancel() -> None: + controller.cancel() + + @reactive.effect + async def _on_stream_complete() -> None: + status = self.latest_message_stream.status() + if status == "running": + return + + swap = chat_client._pending_swap + if swap is None: + return + chat_client._pending_swap = None + new_client, sync = swap + chat_client._swap_client(new_client, sync=sync) + + self._effects.append(_on_cancel) + self._effects.append(_on_stream_complete) + + cancel_bm = self.enable_bookmarking(client, bookmark_on=bookmark_on) + chat_client._cancel_bookmarking = cancel_bm + + setup_greeting(self, chat_client, greeting, self._session) + @overload def on_user_submit(self, fn: UserSubmitFunction) -> Effect_: ... @@ -1674,6 +1739,12 @@ def enable_bookmarking( 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 ------- : @@ -1840,7 +1911,7 @@ def ui( height: "CssUnit" = "auto", fill: bool = True, icon_assistant: HTML | Tag | TagList | None = None, - enable_cancel: bool = False, + enable_cancel: "bool | MISSING_TYPE" = MISSING, footer: Optional[TagChild] = None, **kwargs: TagAttrValue, ) -> Tag: @@ -1877,7 +1948,8 @@ def ui( button in place of the send button while streaming. You must observe ``input._cancel`` on the server and call ``ctrl.cancel()`` on a chatlas ``StreamController`` to actually stop the stream. Defaults to - ``False``. + ``True`` when a ``client=`` was provided to :class:`~shinychat.Chat`, + ``False`` otherwise. footer Optional HTML content to display below the chat input. This can be any HTML content (tags, tag lists, or strings). @@ -1889,6 +1961,12 @@ def ui( Additional attributes for the chat container element. """ + resolved_cancel: bool = ( + self.client is not None + if isinstance(enable_cancel, MISSING_TYPE) + else enable_cancel + ) + return chat_ui( id=self.id, messages=messages, @@ -1898,72 +1976,11 @@ def ui( height=height, fill=fill, icon_assistant=icon_assistant, - enable_cancel=enable_cancel, + enable_cancel=resolved_cancel, footer=footer, **kwargs, ) - def ui_auto( - self, - client: "chatlas.Chat[Any, Any]", - *, - greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, - bookmark_on: "Optional[Literal['response']]" = "response", - **kwargs: Any, - ) -> "ChatAutoServer": - """ - Create a UI element and wire up a chatlas client for this `Chat`. - - This is a convenience method that combines :meth:`ui` and - :func:`~shinychat.chat_auto_server` into a single call. It creates the - chat UI and registers handlers for streaming, cancellation, and optional - bookmarking. - - Parameters - ---------- - client - A chatlas ``Chat`` instance used to generate responses. - greeting - An optional greeting to display at the top of the chat before any - conversation messages. Can be a markdown string, an - :class:`~htmltools.HTML` object, a :class:`~htmltools.Tag`, a - :class:`~shinychat.ChatGreeting`, or a callable that returns any of - the above. - bookmark_on - When to trigger a bookmark. Passed to - :meth:`~shinychat.Chat.enable_bookmarking`. - kwargs - Additional keyword arguments forwarded to :func:`~shinychat.chat_ui`. - - Returns - ------- - : - A :class:`~shinychat.ChatAutoServer` that exposes the wired-up chat. - """ - from ._chat_auto import chat_auto_server - - if callable(greeting) and not isinstance( - greeting, (str, HTML, Tag, TagList, ChatGreeting) - ): - ui_greeting = None - else: - ui_greeting = greeting # type: ignore[assignment] - tag = chat_ui( - id=self.id, - enable_cancel=True, - greeting=ui_greeting, - **kwargs, - ) - - result = chat_auto_server( - self.id, - client, - greeting=greeting, - bookmark_on=bookmark_on, - ) - result._tag = tag - return result - def enable_bookmarking( self, client: "ClientWithState | chatlas.Chat[Any, Any]", @@ -1998,6 +2015,11 @@ def enable_bookmarking( 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 ------- : diff --git a/pkg-py/src/shinychat/_chat_auto.py b/pkg-py/src/shinychat/_chat_auto.py deleted file mode 100644 index 2ae65553..00000000 --- a/pkg-py/src/shinychat/_chat_auto.py +++ /dev/null @@ -1,383 +0,0 @@ -from __future__ import annotations - -import copy -import inspect -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Literal, - Optional, -) - -from htmltools import HTML, Tag, TagList - -from ._chat import Chat, chat_ui -from ._chat_bookmark import CancelCallback -from ._chat_types import ChatGreeting, ChatMessageDict - -if TYPE_CHECKING: - import chatlas - from htmltools import Tagified - from shiny.session import Session - -__all__ = ( - "ChatAutoServer", - "chat_auto_ui", - "chat_auto_server", -) - - -class ChatAutoServer: - """ - A convenience wrapper that wires a :class:`~shinychat.Chat` instance to a chatlas - client. - - This class is normally created via :func:`~shinychat.chat_auto_server` rather than - instantiated directly. It exposes the same surface as :class:`~shinychat.Chat` for - reading messages and updating the UI, while adding higher-level helpers for - swapping the underlying chatlas client and clearing the conversation. - - Parameters - ---------- - chat - The underlying :class:`~shinychat.Chat` (or :class:`~shinychat.ChatExpress`) - instance. - client - A chatlas ``Chat`` client used for response generation. - tag - Optional tag returned by :meth:`tagify` (used in Express). - bookmark_on - Passed through to :meth:`~shinychat.Chat.enable_bookmarking`. - """ - - def __init__( - self, - *, - chat: Chat, - client: "chatlas.Chat[Any, Any]", - tag: Tag | None = None, - bookmark_on: Optional[Literal["response"]] = "response", - ) -> None: - self._chat = chat - self._client = client - self._tag = tag - self._bookmark_on: Optional[Literal["response"]] = bookmark_on - self._pending_swap: "tuple[chatlas.Chat[Any, Any], bool] | None" = None - self._cancel_bookmarking: CancelCallback | None = None - - # ------------------------------------------------------------------ - # Express rendering - # ------------------------------------------------------------------ - - def tagify(self) -> "Tagified": - """Return the tag for Express rendering. - - Raises - ------ - RuntimeError - If no tag was provided at construction time (i.e. Core mode). - """ - if self._tag is None: - raise RuntimeError( - "tagify() is only available when ChatAutoServer was created " - "via Chat.ui_auto() in Express mode." - ) - return self._tag.tagify() - - # ------------------------------------------------------------------ - # Properties - # ------------------------------------------------------------------ - - @property - def chat(self) -> Chat: - """The underlying :class:`~shinychat.Chat` instance.""" - return self._chat - - @property - def client(self) -> "chatlas.Chat[Any, Any]": - """The current chatlas client. - - Assigning to this property is equivalent to calling - :meth:`set_client` with ``sync=True``. - """ - return self._client - - @client.setter - def client(self, new_client: "chatlas.Chat[Any, Any]") -> None: - self.set_client(new_client, sync=True) - - # ------------------------------------------------------------------ - # Module-specific - # ------------------------------------------------------------------ - - def set_client( - self, - new_client: "chatlas.Chat[Any, Any]", - *, - sync: bool = True, - ) -> None: - """Replace the chatlas client. - - Parameters - ---------- - new_client - The new chatlas ``Chat`` client to use. - sync - If ``True`` (the default), copy the current client's turns, system - prompt, and tools to the new client before swapping. - """ - if self._chat.latest_message_stream.status() == "running": - self._pending_swap = (new_client, sync) - else: - self._swap_client(new_client, sync=sync) - - def _swap_client( - self, - new_client: "chatlas.Chat[Any, Any]", - *, - sync: bool, - ) -> None: - if sync: - old = self._client - new_client.set_turns(old.get_turns()) - if old.system_prompt is not None: - new_client.system_prompt = old.system_prompt - # chatlas get_tools() returns Tool|ToolBuiltIn but set_tools() doesn't accept ToolBuiltIn - new_client.set_tools(old.get_tools()) # type: ignore[arg-type] - - self._client = new_client - - # Re-register bookmarking with the new client - if self._cancel_bookmarking is not None: - self._cancel_bookmarking() - self._cancel_bookmarking = None - cancel = self._chat.enable_bookmarking( - new_client, bookmark_on=self._bookmark_on - ) - self._cancel_bookmarking = cancel - - async def clear( - self, - *, - messages: list[ChatMessageDict] | None = None, - greeting: bool = False, - client_history: Literal["clear", "set", "append", "keep"] = "clear", - ) -> None: - """Clear the chat UI and manage client history. - - Parameters - ---------- - messages - Optional list of message dicts to display in the chat after clearing. - greeting - If ``True``, also clear the greeting (triggers re-request). - client_history - How to handle the chatlas client's turn history: - - - ``"clear"``: wipe turns. - - ``"set"``: replace with ``messages`` (converted to turns). - - ``"append"``: add ``messages`` to existing turns. - - ``"keep"``: leave turns untouched. - """ - if messages is None and client_history in ("set", "append"): - raise ValueError( - f"client_history='{client_history}' requires `messages` to be provided." - ) - - await self._chat.clear_messages(greeting=greeting) - - if messages is not None: - for msg in messages: - await self._chat.append_message(msg) - - if client_history == "clear": - self._client.set_turns([]) - elif client_history == "set": - assert messages is not None - turns = messages_to_turns(messages) - self._client.set_turns(turns) - elif client_history == "append": - assert messages is not None - existing = self._client.get_turns() - extra = messages_to_turns(messages) - self._client.set_turns(existing + extra) - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def chat_auto_ui( - id: str, - **kwargs: Any, -) -> Tag: - """UI container for an auto-wired chat component (Shiny Core). - - A thin wrapper around :func:`~shinychat.chat_ui` with ``enable_cancel=True`` - pre-set. All keyword arguments are forwarded to :func:`~shinychat.chat_ui`. - - Parameters - ---------- - id - A unique identifier for the chat UI. - kwargs - Keyword arguments forwarded to :func:`~shinychat.chat_ui`. - """ - return chat_ui( - id, - enable_cancel=True, - **kwargs, - ) - - -def chat_auto_server( - id: str, - client: "chatlas.Chat[Any, Any]", - *, - greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, - bookmark_on: Optional[Literal["response"]] = "response", -) -> ChatAutoServer: - """Wire up a chatlas client to a chat UI with streaming, cancellation, and bookmarking. - - Creates a :class:`~shinychat.Chat`, registers handlers for user input, - cancellation, and optional bookmarking, then returns a - :class:`~shinychat.ChatAutoServer` that wraps it all. - - Parameters - ---------- - id - The chat component ID (must match the corresponding ``chat_auto_ui(id)`` call). - client - A chatlas ``Chat`` instance used to generate responses. - greeting - Optional greeting content. Can be: - - * A static string/HTML/tag — displayed when the chat first appears. - * A callable — called (with an optional ``client`` keyword argument) each - time a greeting is requested; the return value is passed to - :meth:`~shinychat.Chat.set_greeting`. - bookmark_on - When to trigger a bookmark. Passed to - :meth:`~shinychat.Chat.enable_bookmarking`. - - Returns - ------- - : - A :class:`~shinychat.ChatAutoServer` that exposes the wired-up chat. - """ - from chatlas import StreamController - from shiny import reactive - from shiny.session import require_active_session - - session = require_active_session(None) - chat = Chat(id) - - controller = StreamController() - - result = ChatAutoServer( - chat=chat, - client=client, - bookmark_on=bookmark_on, - ) - - @chat.on_user_submit - async def _on_user_submit(user_input: str) -> None: - response = await result.client.stream_async( - user_input, - content="all", - controller=controller, - ) - await chat.append_message_stream(response) - - cancel_input_id = f"{id}_cancel" - - @reactive.effect - @reactive.event(session.input[cancel_input_id]) - async def _on_cancel() -> None: - controller.cancel() - - @reactive.effect - async def _on_stream_complete() -> None: - status = chat.latest_message_stream.status() - if status == "running": - return - - swap = result._pending_swap - if swap is None: - return - result._pending_swap = None - new_client, sync = swap - result._swap_client(new_client, sync=sync) - - cancel_bm = chat.enable_bookmarking(client, bookmark_on=bookmark_on) - result._cancel_bookmarking = cancel_bm - - setup_greeting(chat, result, greeting, session) - - return result - - -# --------------------------------------------------------------------------- -# Module-level helpers -# --------------------------------------------------------------------------- - - -def messages_to_turns( - messages: list[ChatMessageDict], -) -> list["chatlas.Turn"]: - """Convert a list of ``ChatMessageDict`` objects to chatlas ``Turn`` objects.""" - from chatlas import Turn - - turns: list[Turn] = [] - for msg in messages: - role_raw = msg.get("role", "assistant") - content = msg.get("content", "") - if role_raw == "user": - role: Literal["user", "assistant"] = "user" - else: - role = "assistant" - turns.append(Turn(content, role=role)) - return turns - - -def setup_greeting( - chat: Chat, - result: "ChatAutoServer", - greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None", - session: "Session", -) -> None: - """Wire up greeting handling for ``chat_auto_server``.""" - from shiny import reactive - - if greeting is None: - return - - greeting_input_id = f"{chat.id}_greeting_requested" - - if isinstance(greeting, (str, HTML, Tag, TagList, ChatGreeting)): - static_greeting = greeting - - @reactive.effect - @reactive.event(session.input[greeting_input_id]) - async def _handle_static_greeting() -> None: - await chat.set_greeting(static_greeting) - - elif callable(greeting): - fn = greeting - fn_params = inspect.signature(fn).parameters - has_client_param = "client" in fn_params - - @reactive.effect - @reactive.event(session.input[greeting_input_id]) - async def _handle_greeting() -> None: - if has_client_param: - greeting_client = copy.deepcopy(result.client) - greeting_client.set_turns([]) - g = fn(client=greeting_client) - else: - g = fn() - if inspect.isawaitable(g): - g = await g - await chat.set_greeting(g) diff --git a/pkg-py/src/shinychat/_chat_client.py b/pkg-py/src/shinychat/_chat_client.py new file mode 100644 index 00000000..4bfa34ea --- /dev/null +++ b/pkg-py/src/shinychat/_chat_client.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import copy +import inspect +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Optional, +) + +if TYPE_CHECKING: + import chatlas + from htmltools import HTML, Tag, TagList + from shiny import Session + + from ._chat import Chat + from ._chat_types import ChatGreeting, ChatMessageDict + + +class ChatClient: + """ + Wraps a chatlas client bound to a :class:`~shinychat.Chat` instance. + + This class is created automatically when you pass a ``client=`` argument to + :class:`~shinychat.Chat`. It holds the current client, handles deferred swaps + during streaming, and wires up bookmarking. + """ + + def __init__( + self, + *, + chat: "Chat", + client: "chatlas.Chat[Any, Any]", + bookmark_on: "Optional[Literal['response']]" = "response", + ) -> None: + self._chat = chat + self._client = client + self._bookmark_on: Optional[Literal["response"]] = bookmark_on + # (new_client, sync) stored when a swap is requested mid-stream + self._pending_swap: tuple["chatlas.Chat[Any, Any]", bool] | None = None + self._cancel_bookmarking: Any = None + + @property + def value(self) -> "chatlas.Chat[Any, Any]": + """The underlying chatlas client.""" + return self._client + + def set( + self, + new_client: "chatlas.Chat[Any, Any]", + *, + sync: bool = True, + ) -> None: + """ + Replace the chatlas client. + + If a response stream is currently running the swap is deferred until + the stream completes. + + Parameters + ---------- + new_client + The replacement chatlas client. + sync + When ``True`` (the default), copies turns, system_prompt, and tools + from the old client to the new one before swapping. + """ + status = self._chat.latest_message_stream.status() + if status == "running": + self._pending_swap = (new_client, sync) + else: + self._swap_client(new_client, sync=sync) + + def _swap_client( + self, + new_client: "chatlas.Chat[Any, Any]", + *, + sync: bool, + ) -> None: + """Perform the actual client replacement.""" + if sync: + old = self._client + new_client.set_turns(old.get_turns()) + old_sp = old.system_prompt + if old_sp is not None: + new_client.system_prompt = old_sp + # get_tools() returns list[Tool | ToolBuiltIn]; set_tools() accepts + # list[Callable | Tool] — the types overlap at runtime even though + # pyright can't prove it through the invariant list parameter. + new_client.set_tools(old.get_tools()) # type: ignore[arg-type] + + self._client = new_client + + # Cancel old bookmarking and re-register with the new client + if self._cancel_bookmarking is not None: + self._cancel_bookmarking() + self._cancel_bookmarking = None + + cancel = self._chat.enable_bookmarking( + new_client, bookmark_on=self._bookmark_on + ) + self._cancel_bookmarking = cancel + + async def clear( + self, + *, + messages: "list[ChatMessageDict] | None" = None, + greeting: bool = False, + client_history: str = "clear", + ) -> None: + """ + Clear chat messages and optionally reset the client's turn history. + + Parameters + ---------- + messages + A list of messages to set or append when ``client_history`` is + ``"set"`` or ``"append"``. Required for those modes. + greeting + Passed to :meth:`~shinychat.Chat.clear_messages`. + client_history + How to handle the client's turn history: + + * ``"clear"`` (default): removes all turns from the client. + * ``"set"``: sets the client's turns to ``messages``. Requires + ``messages`` to be provided. + * ``"append"``: appends ``messages`` to the client's existing turns. + Requires ``messages`` to be provided. + * ``"keep"``: leaves the client's turns untouched. + """ + if client_history in ("set", "append") and messages is None: + raise ValueError( + f"`messages` must be provided when `client_history='{client_history}'`." + ) + + await self._chat.clear_messages(greeting=greeting) + + if messages is not None: + for msg in messages: + await self._chat.append_message(msg) + + if client_history == "clear": + self._client.set_turns([]) + elif client_history == "set": + assert messages is not None + turns = messages_to_turns(messages) + self._client.set_turns(turns) + elif client_history == "append": + assert messages is not None + turns = self._client.get_turns() + messages_to_turns(messages) + self._client.set_turns(turns) + # "keep" → do nothing + + +def messages_to_turns( + messages: "list[ChatMessageDict]", +) -> "list[chatlas.Turn[Any]]": + """ + Convert a list of :class:`~shinychat.types.ChatMessageDict` dicts to + a list of :class:`chatlas.Turn` objects. + """ + from chatlas import Turn + + turns: list[Any] = [] + for msg in messages: + role = msg.get("role", "assistant") + content = msg.get("content", "") + turns.append(Turn(content, role=role)) + return turns + + +def setup_greeting( + chat: "Chat", + chat_client: "ChatClient", + greeting: "str | HTML | Tag | TagList | ChatGreeting | Any | None", + session: "Session", +) -> None: + """ + Wire up greeting handling for a client-backed chat. + + For static greetings (str / HTML / Tag / TagList / ChatGreeting), registers + a reactive effect on ``{id}_greeting_requested`` that sends the greeting. + + For callable greetings the function is inspected for a ``client`` parameter. + If present, a deep-copy of the client with empty turns is passed to it. + """ + if greeting is None: + return + + from shiny import reactive + from shiny.session import session_context + + if callable(greeting) and not _is_static_greeting(greeting): + fn = greeting + sig = inspect.signature(fn) + + with session_context(session): + + @reactive.effect + @reactive.event(session.input[f"{chat.id}_greeting_requested"]) + async def _on_greeting_requested() -> None: + if "client" in sig.parameters: + client_copy = copy.deepcopy(chat_client.value) + client_copy.set_turns([]) + result = fn(client=client_copy) + else: + result = fn() + + if inspect.isawaitable(result): + result = await result + + # result is the greeting content from the callable + await chat.set_greeting(result) # type: ignore[arg-type] + + chat._effects.append(_on_greeting_requested) + else: + # Static greeting — wire up the greeting_requested input + # Cast: we've verified greeting is a static type (str/HTML/Tag/TagList/ChatGreeting) + from htmltools import HTML, Tag, TagList + + from ._chat_types import ChatGreeting + + if isinstance(greeting, (str, HTML, Tag, TagList, ChatGreeting)): + static_greeting = greeting + else: + static_greeting = str(greeting) + + with session_context(session): + + @reactive.effect + @reactive.event(session.input[f"{chat.id}_greeting_requested"]) + async def _on_greeting_requested_static() -> None: + await chat.set_greeting(static_greeting) + + chat._effects.append(_on_greeting_requested_static) + + +def _is_static_greeting(obj: Any) -> bool: + """Return True if obj is a str/HTML/Tag/TagList/ChatGreeting (not a callable greeting).""" + from htmltools import HTML, Tag, TagList + + from ._chat_types import ChatGreeting + + return isinstance(obj, (str, HTML, Tag, TagList, ChatGreeting)) diff --git a/pkg-py/src/shinychat/types/__init__.py b/pkg-py/src/shinychat/types/__init__.py index 2b4b1ac9..8d9c1a6d 100644 --- a/pkg-py/src/shinychat/types/__init__.py +++ b/pkg-py/src/shinychat/types/__init__.py @@ -1,5 +1,5 @@ from .._chat import ChatMessage, ChatMessageDict -from .._chat_auto import ChatAutoServer +from .._chat_client import ChatClient from .._chat_types import ChatGreeting try: @@ -18,7 +18,7 @@ def __init__(self, *args, **kwargs): __all__ = [ - "ChatAutoServer", + "ChatClient", "ChatGreeting", "ChatMessage", "ChatMessageDict", diff --git a/pkg-py/tests/playwright/chat/auto_cancel_core/app.py b/pkg-py/tests/playwright/chat/auto_cancel_core/app.py index 6f61d5d6..616ada78 100644 --- a/pkg-py/tests/playwright/chat/auto_cancel_core/app.py +++ b/pkg-py/tests/playwright/chat/auto_cancel_core/app.py @@ -2,8 +2,7 @@ import chatlas from shiny import App, Inputs, Outputs, Session, reactive, render, ui - -from shinychat import chat_auto_server, chat_auto_ui +from shinychat import Chat, chat_ui class ObservableStreamController(chatlas.StreamController): @@ -64,7 +63,7 @@ async def set_state(self, state: object) -> None: app_ui = ui.page_fillable( - chat_auto_ui("chat"), + chat_ui("chat", enable_cancel=True), ui.output_code("cancel_requested"), ) @@ -75,10 +74,11 @@ def server(input: Inputs, output: Outputs, session: Session) -> None: ObservableStreamController.cancel_requested = cancel_requested_value client = SlowChatClient() - chat = chat_auto_server("chat", client) + Chat("chat", client=client) # type: ignore[arg-type] @render.code def cancel_requested() -> str: return str(cancel_requested_value()) + app = App(app_ui, server) diff --git a/pkg-py/tests/playwright/chat/auto_core/app.py b/pkg-py/tests/playwright/chat/auto_core/app.py index a8df4b67..c29978ba 100644 --- a/pkg-py/tests/playwright/chat/auto_core/app.py +++ b/pkg-py/tests/playwright/chat/auto_core/app.py @@ -1,5 +1,5 @@ from shiny import App, Inputs, Outputs, Session, render, ui -from shinychat import chat_auto_server, chat_auto_ui +from shinychat import Chat, chat_ui class MockChatClient: @@ -49,18 +49,18 @@ async def set_state(self, state: object) -> None: app_ui = ui.page_fillable( - chat_auto_ui("chat"), + chat_ui("chat", enable_cancel=True), ui.output_code("message_state"), ) def server(input: Inputs, output: Outputs, session: Session) -> None: client = MockChatClient() - chat = chat_auto_server("chat", client, greeting="Welcome!") + chat = Chat("chat", client=client, greeting="Welcome!") # type: ignore[arg-type] @render.code def message_state(): - return str(chat.chat.messages()) + return str(chat.messages()) app = App(app_ui, server) diff --git a/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py b/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py index 68802f91..84da423d 100644 --- a/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py +++ b/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py @@ -29,6 +29,8 @@ def test_sending_message_gets_response(page: Page, local_app: ShinyAppProc) -> N chat.set_user_input(user_message) chat.send_user_input(method="enter") chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) + + def test_messages_state_updated_after_exchange( page: Page, local_app: ShinyAppProc ) -> None: @@ -42,7 +44,6 @@ def test_messages_state_updated_after_exchange( chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) message_state = controller.OutputCode(page, "message_state") - # Verify that both user and assistant messages appear in state message_state.expect.to_contain_text("user", timeout=10_000) message_state.expect.to_contain_text("assistant", timeout=10_000) message_state.expect.to_contain_text(user_message, timeout=10_000) diff --git a/pkg-py/tests/playwright/chat/auto_express/app.py b/pkg-py/tests/playwright/chat/auto_express/app.py index f3fa4665..e8dafdf0 100644 --- a/pkg-py/tests/playwright/chat/auto_express/app.py +++ b/pkg-py/tests/playwright/chat/auto_express/app.py @@ -50,14 +50,13 @@ async def set_state(self, state: object) -> None: client = MockChatClient() -chat = Chat(id="chat") -auto = chat.ui_auto(client, greeting="Welcome to the test!") -auto +chat = Chat(id="chat", client=client, greeting="Welcome to the test!") # type: ignore[arg-type] +chat.ui() -"auto.messages():" +"chat.messages():" @render.code def message_state(): - return str(auto.chat.messages()) + return str(chat.messages()) diff --git a/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py b/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py index 895a50f4..b3851a71 100644 --- a/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py +++ b/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py @@ -33,6 +33,8 @@ def test_send_message_gets_response(page: Page, local_app: ShinyAppProc) -> None chat.set_user_input(user_message) chat.send_user_input(method="enter") chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) + + def test_messages_reflect_conversation(page: Page, local_app: ShinyAppProc) -> None: page.goto(local_app.url) chat = ChatController(page, "chat") @@ -45,6 +47,5 @@ def test_messages_reflect_conversation(page: Page, local_app: ShinyAppProc) -> N chat.send_user_input(method="enter") chat.expect_latest_message(f"You said: {user_message}", timeout=30_000) - # messages() should contain both user and assistant turns message_state.expect.to_contain_text(user_message, timeout=10_000) message_state.expect.to_contain_text(f"You said: {user_message}", timeout=10_000) diff --git a/pkg-py/tests/playwright/chat/bookmark_no_store/app.py b/pkg-py/tests/playwright/chat/bookmark_no_store/app.py index 6a983209..77fe1792 100644 --- a/pkg-py/tests/playwright/chat/bookmark_no_store/app.py +++ b/pkg-py/tests/playwright/chat/bookmark_no_store/app.py @@ -8,7 +8,7 @@ def __init__(self) -> None: self.turns: list[object] = [] async def get_state(self) -> Jsonifiable: - return {"version": 1, "turns": self.turns} + return {"version": 1, "turns": self.turns} # type: ignore[return-value] async def set_state(self, state: Jsonifiable) -> None: assert isinstance(state, dict) diff --git a/pkg-py/tests/pytest/test_chat_auto.py b/pkg-py/tests/pytest/test_chat_auto.py index e0a75e80..621d0422 100644 --- a/pkg-py/tests/pytest/test_chat_auto.py +++ b/pkg-py/tests/pytest/test_chat_auto.py @@ -1,20 +1,31 @@ from __future__ import annotations -import pytest -from typing import cast +import asyncio +import threading +from typing import Any, cast -from htmltools import Tag, tags -from shiny import Session +import pytest +from htmltools import tags +from shiny import Inputs, Session from shiny.module import ResolvedId from shiny.session import session_context -from shinychat import Chat, ChatAutoServer, chat_auto_ui -from shinychat._chat_auto import messages_to_turns +from shinychat import Chat, chat_ui +from shinychat._chat_client import ChatClient, messages_to_turns +from shinychat._chat_types import ChatMessageDict + +# --------------------------------------------------------------------------- +# Test session / mock client helpers +# --------------------------------------------------------------------------- class _MockSession: ns: ResolvedId = ResolvedId("") app: object = None id: str = "mock-session" + input: Inputs + + def __init__(self) -> None: + self.input = Inputs({}, ns=ResolvedId) def on_ended(self, callback: object) -> None: pass @@ -32,177 +43,183 @@ def is_stub_session(self) -> bool: test_session = cast(Session, _MockSession()) -class MockClient: - def __init__(self) -> None: - self._turns: list[object] = [] - self.system_prompt: str | None = None - self._tools: list[object] = [] +def _run_async(coro_fn: Any) -> None: + """Run an async function in a separate thread to avoid event loop conflicts.""" + exc: list[BaseException] = [] + + def _target() -> None: + try: + asyncio.run(coro_fn()) + except BaseException as err: + exc.append(err) + + thread = threading.Thread(target=_target) + thread.start() + thread.join() + if exc: + raise exc[0] + - def get_turns(self) -> list[object]: +class MockClient: + """Minimal chatlas-like client for unit testing.""" + + def __init__( + self, + *, + turns: list[Any] | None = None, + system_prompt: str | None = None, + tools: list[Any] | None = None, + ) -> None: + self._turns: list[Any] = turns if turns is not None else [] + self.system_prompt: str | None = system_prompt + self._tools: list[Any] = tools if tools is not None else [] + + def get_turns(self) -> list[Any]: return list(self._turns) - def set_turns(self, turns: list[object]) -> None: + def set_turns(self, turns: list[Any]) -> None: self._turns = list(turns) - def get_tools(self) -> list[object]: + def get_tools(self) -> list[Any]: return list(self._tools) - def set_tools(self, tools: list[object]) -> None: + def set_tools(self, tools: list[Any]) -> None: self._tools = list(tools) -def make_auto( - client: MockClient | None = None, -) -> tuple[ChatAutoServer, MockClient]: - client = client or MockClient() +def make_chat() -> tuple[Chat, MockClient]: + """Return (Chat, MockClient) where Chat is wired up with client=.""" + mock = MockClient() with session_context(test_session): - chat = Chat("test") - auto = ChatAutoServer(chat=chat, client=client) - return auto, client + chat = Chat("test", client=cast(Any, mock)) # type: ignore[arg-type] + return chat, mock # --------------------------------------------------------------------------- -# chat_auto_ui() +# ChatClient construction via Chat(client=) # --------------------------------------------------------------------------- -def test_chat_auto_ui_sets_id_and_enables_cancel(): - with session_context(test_session): - result = chat_auto_ui("myid") - assert isinstance(result, Tag) - html = result.get_html_string() - assert 'id="myid"' in html - assert "enable-cancel" in html - - -def test_chat_auto_ui_forwards_kwargs(): +def test_client_is_none_without_client(): with session_context(test_session): - result = chat_auto_ui( - "myid", - placeholder="Ask me anything...", - height="400px", - greeting="Hello there!", - footer=tags.div("my footer"), - icon_assistant=tags.span("bot"), - ) - html = result.get_html_string() - assert "Ask me anything..." in html - assert "400px" in html - assert "Hello there!" in html - assert "my footer" in html - assert "bot" in html + chat = Chat("test_no_client") + assert chat.client is None -# --------------------------------------------------------------------------- -# ChatAutoServer properties -# --------------------------------------------------------------------------- +def test_client_is_chat_client_with_client(): + chat, _ = make_chat() + assert isinstance(chat.client, ChatClient) -def test_chat_and_client_properties(): - client = MockClient() - auto, _ = make_auto(client) - assert auto.chat is auto._chat - assert auto.client is client +def test_client_value_returns_raw_client(): + chat, mock = make_chat() + assert chat.client is not None + assert chat.client.value is mock # --------------------------------------------------------------------------- -# _swap_client +# ChatClient._swap_client — sync / no-sync # --------------------------------------------------------------------------- -def test_swap_client_sync_copies_state(): - auto, old = make_auto() - old._turns = ["t1", "t2"] - old.system_prompt = "Be helpful" - old._tools = ["tool_a"] +def test_set_sync_copies_state(): + mock_old = MockClient( + turns=["turn1"], + system_prompt="be helpful", + tools=["tool_a"], + ) + mock_new = MockClient() - new = MockClient() with session_context(test_session): - auto._swap_client(new, sync=True) + chat = Chat("test_sync", client=cast(Any, mock_old)) # type: ignore[arg-type] - assert auto.client is new - assert new._turns == ["t1", "t2"] - assert new.system_prompt == "Be helpful" - assert new._tools == ["tool_a"] + assert chat.client is not None + chat.client._swap_client(cast(Any, mock_new), sync=True) + assert mock_new.get_turns() == ["turn1"] + assert mock_new.system_prompt == "be helpful" + assert mock_new.get_tools() == ["tool_a"] -def test_swap_client_no_sync_skips_copy(): - auto, old = make_auto() - old._turns = ["t1"] - old.system_prompt = "Be helpful" - old._tools = ["tool_a"] - new = MockClient() +def test_set_no_sync_skips_copy(): + mock_old = MockClient( + turns=["turn1"], + system_prompt="be helpful", + tools=["tool_a"], + ) + mock_new = MockClient() + with session_context(test_session): - auto._swap_client(new, sync=False) + chat = Chat("test_nosync", client=cast(Any, mock_old)) # type: ignore[arg-type] + + assert chat.client is not None + chat.client._swap_client(cast(Any, mock_new), sync=False) - assert auto.client is new - assert new._turns == [] - assert new.system_prompt is None - assert new._tools == [] + assert mock_new.get_turns() == [] + assert mock_new.system_prompt is None + assert mock_new.get_tools() == [] -def test_swap_client_skips_none_system_prompt(): - auto, old = make_auto() - old.system_prompt = None +def test_set_skips_none_system_prompt(): + """A None system_prompt on the old client should not overwrite non-None on the new one.""" + mock_old = MockClient(system_prompt=None) + mock_new = MockClient(system_prompt="keep me") - new = MockClient() - new.system_prompt = "Keep me" with session_context(test_session): - auto._swap_client(new, sync=True) + chat = Chat("test_sp", client=cast(Any, mock_old)) # type: ignore[arg-type] - assert new.system_prompt == "Keep me" + assert chat.client is not None + chat.client._swap_client(cast(Any, mock_new), sync=True) + + # system_prompt should be untouched because old had None + assert mock_new.system_prompt == "keep me" # --------------------------------------------------------------------------- -# clear() — validation +# ChatClient.clear — validation # --------------------------------------------------------------------------- def test_clear_rejects_set_without_messages(): - import asyncio + chat, _ = make_chat() + assert chat.client is not None + + with pytest.raises(ValueError, match="messages.*must be provided"): - auto, _ = make_auto() - loop = asyncio.new_event_loop() - try: - with pytest.raises(ValueError, match="client_history='set'"): - loop.run_until_complete(auto.clear(client_history="set")) - finally: - loop.close() + async def _run() -> None: + assert chat.client is not None + await chat.client.clear(client_history="set") + + _run_async(_run) def test_clear_rejects_append_without_messages(): - import asyncio + chat, _ = make_chat() + assert chat.client is not None + + with pytest.raises(ValueError, match="messages.*must be provided"): - auto, _ = make_auto() - loop = asyncio.new_event_loop() - try: - with pytest.raises(ValueError, match="client_history='append'"): - loop.run_until_complete(auto.clear(client_history="append")) - finally: - loop.close() + async def _run() -> None: + assert chat.client is not None + await chat.client.clear(client_history="append") + + _run_async(_run) # --------------------------------------------------------------------------- -# messages_to_turns +# messages_to_turns helper # --------------------------------------------------------------------------- def test_messages_to_turns_basic(): - msgs = [ - {"role": "user", "content": "hello"}, - {"role": "assistant", "content": "hi there"}, - {"role": "user", "content": "bye"}, + msgs: list[ChatMessageDict] = [ + {"content": "hi", "role": "user"}, + {"content": "hello", "role": "assistant"}, ] turns = messages_to_turns(msgs) - - assert len(turns) == 3 + assert len(turns) == 2 assert turns[0].role == "user" - assert turns[0].text == "hello" assert turns[1].role == "assistant" - assert turns[1].text == "hi there" - assert turns[2].role == "user" - assert turns[2].text == "bye" def test_messages_to_turns_empty(): @@ -210,40 +227,46 @@ def test_messages_to_turns_empty(): def test_messages_to_turns_defaults_to_assistant(): - turns = messages_to_turns([{"content": "no role"}]) + msgs: list[ChatMessageDict] = [{"content": "x", "role": "assistant"}] + turns = messages_to_turns(msgs) assert turns[0].role == "assistant" - assert turns[0].text == "no role" # --------------------------------------------------------------------------- -# tagify +# chat_ui helpers # --------------------------------------------------------------------------- -def test_tagify_raises_without_tag(): - auto, _ = make_auto() - with pytest.raises(RuntimeError, match="tagify"): - auto.tagify() +def test_chat_ui_with_enable_cancel(): + tag = chat_ui("myid", enable_cancel=True) + html = tag.get_html_string() + assert "enable-cancel" in html -def test_tagify_delegates_to_tag(): - auto, _ = make_auto() - with session_context(test_session): - tag = chat_auto_ui("test") - auto._tag = tag - result = auto.tagify() - assert result is not None +def test_chat_ui_forwards_kwargs(): + icon = tags.span("🤖") + tag = chat_ui( + "myid", + placeholder="Ask me anything", + height="400px", + greeting="Hello!", + footer=tags.p("footer text"), + icon_assistant=icon, + ) + html = tag.get_html_string() + assert "Ask me anything" in html + assert "400px" in html + assert "Hello!" in html + assert "footer text" in html # --------------------------------------------------------------------------- -# Exports +# Public exports — skipped until Task 4 updates exports # --------------------------------------------------------------------------- -def test_public_exports(): - from shinychat import ChatAutoServer, chat_auto_server, chat_auto_ui - from shinychat.types import ChatAutoServer as TypesChatAutoServer +def test_public_exports() -> None: + from shinychat import ChatClient as CC1 + from shinychat.types import ChatClient as CC2 # type: ignore[attr-defined] - assert callable(chat_auto_ui) - assert callable(chat_auto_server) - assert ChatAutoServer is TypesChatAutoServer + assert CC1 is CC2 From 48840e4844aefff1f60922fc3b2231f1fc6bedc3 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 27 May 2026 10:46:53 -0500 Subject: [PATCH 3/4] chore: clean up changelog, move ChatClient export to types-only --- pkg-py/CHANGELOG.md | 25 +++++++++++++++++++------ pkg-py/docs/_quarto.yml | 2 +- pkg-py/src/shinychat/__init__.py | 2 -- pkg-py/tests/pytest/test_chat_auto.py | 7 +++---- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 0bb15621..6dbf6aeb 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -5,28 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0] - Unreleased +## [Unreleased] ### New features * `Chat()` now accepts an optional `client=` parameter. When provided, streaming, cancellation, bookmarking, and greeting handling are wired up automatically — no manual plumbing required. The `chat.client` property exposes a `ChatClient` wrapper with `.value` (the raw chatlas client), `.set()` for swapping models mid-session, and `.clear()` for resetting the conversation with flexible history management. -* Added `footer` parameter to `chat_ui()` and `Chat.ui()` for displaying arbitrary HTML content below the chat input. Useful for disclaimers, attribution, or interactive toolbars. Styled with sensible defaults and customizable via `--shiny-chat-footer-font-size` and `--shiny-chat-footer-color` CSS custom properties. (#224) +## [0.4.0] - 2026-05-26 -* Added `chat_greeting()` for creating welcome messages that appear when the chat is empty. Greetings can be set statically via `chat_ui(greeting=)` or dynamically with `Chat.set_greeting()`, and are dismissed when the user sends their first message. A new `{id}_greeting_requested` input fires when the chat is visible, empty, and has no greeting, enabling LLM-generated welcome messages. (#217) +### New features -* Tool result cards now render images and PDFs returned by chatlas tools. When a tool returns `ContentImageInline`, `ContentImageRemote`, or `ContentPDF`, the result is displayed as an inline image or a PDF filename badge. Mixed content lists (e.g., `[ContentText("summary"), content_image_file("plot.png")]`) are rendered with items interleaved in order. Standalone image and PDF content items in turn history are also rendered correctly. (#225) +* The chat UI now displays model reasoning/thinking content as collapsible panels above assistant responses. Thinking content streams in real-time with animated topic labels. This works with providers that support structured thinking (e.g., Claude's extended thinking via `chatlas`) and with local models that wrap reasoning in `` tags. (#208) + +* Added `chat_greeting()` for creating welcome messages that appear when the chat is empty. Greetings can be set statically via `chat_ui(greeting=)` or dynamically with `Chat.set_greeting()`, and are dismissed when the user sends their first message. A new `{id}_greeting_requested` input fires when the chat is visible, empty, and has no greeting, enabling LLM-generated welcome messages. (#217) * Added `enable_cancel` parameter to `chat_ui()` and `Chat.ui()` to show a stop button that lets users cancel an in-progress AI response. Press the stop button or hit Escape to cancel. Wire up the `input._cancel` event to a `chatlas.StreamController` (introduced in chatlas v0.18.0) to connect the UI to your chat provider. (#221) -* Markdown lists where every item is a `` are now rendered as a grid of clickable suggestion cards. Each suggestion's text content becomes both the card label and the value sent on click. To add a short heading above the body text, set the `title` attribute on the span — e.g. `Body text shown on the card.`. Only the body text (not the title) is submitted when the card is clicked. Cards stream in with staggered animations and support keyboard navigation (arrow keys, Home/End) with roving tabindex. (#219) +* Added `footer` parameter to `chat_ui()` and `Chat.ui()` for displaying arbitrary HTML content below the chat input. Useful for disclaimers, attribution, or interactive toolbars. Styled with sensible defaults and customizable via `--shiny-chat-footer-font-size` and `--shiny-chat-footer-color` CSS custom properties. (#224) -* The chat UI now displays model reasoning/thinking content as collapsible panels above assistant responses. Thinking content streams in real-time with animated topic labels. This works with providers that support structured thinking (e.g., Claude's extended thinking via `chatlas`) and with local models that wrap reasoning in `` tags. (#208) +* The chat input now supports history navigation: press Up/Down arrow keys when the input is empty to cycle through previously sent messages. Editing a recalled message locks navigation until the input is cleared. (#222) ### Improvements +* Tool result cards now render images and PDFs returned by chatlas tools. When a tool returns `ContentImageInline`, `ContentImageRemote`, or `ContentPDF`, the result is displayed as an inline image or a PDF filename badge. Mixed content lists (e.g., `[ContentText("summary"), content_image_file("plot.png")]`) are rendered with items interleaved in order. Standalone image and PDF content items in turn history are also rendered correctly. (#225) + +* Markdown lists where every item is a `` are now rendered as a grid of clickable suggestion cards. Each suggestion's text content becomes both the card label and the value sent on click. To add a short heading above the body text, set the `title` attribute on the span — e.g. `Body text shown on the card.`. Only the body text (not the title) is submitted when the card is clicked. Cards stream in with staggered animations and support keyboard navigation (arrow keys, Home/End) with roving tabindex. (#219) + * Updated minimum `chatlas` version to `>=0.15.0`. (#208) +### Bug fixes + +* Fixed a circular import error triggered when `import shinychat` ran before `shiny` had fully initialized. (#212) + +* Fixed extra newlines appearing when copying user message text. (#209) + + ## [0.3.2] - 2026-05-21 ### Bug fixes diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index d546d595..060a20a7 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -69,7 +69,7 @@ quartodoc: include_functions: true contents: - Chat - - ChatClient + - types.ChatClient - chat_ui - chat_greeting - title: Shiny Express diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index df5f0f47..34d9e89a 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,11 +1,9 @@ from ._chat import Chat, chat_greeting, chat_ui -from ._chat_client import ChatClient from ._chat_normalize import message_content, message_content_chunk from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ "Chat", - "ChatClient", "chat_greeting", "chat_ui", "MarkdownStream", diff --git a/pkg-py/tests/pytest/test_chat_auto.py b/pkg-py/tests/pytest/test_chat_auto.py index 621d0422..0f51413a 100644 --- a/pkg-py/tests/pytest/test_chat_auto.py +++ b/pkg-py/tests/pytest/test_chat_auto.py @@ -261,12 +261,11 @@ def test_chat_ui_forwards_kwargs(): # --------------------------------------------------------------------------- -# Public exports — skipped until Task 4 updates exports +# Public exports # --------------------------------------------------------------------------- def test_public_exports() -> None: - from shinychat import ChatClient as CC1 - from shinychat.types import ChatClient as CC2 # type: ignore[attr-defined] + from shinychat.types import ChatClient as CC - assert CC1 is CC2 + assert CC is ChatClient From 3bcd5963f333094c92e176d1b6d09ab0744f7d00 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 27 May 2026 10:59:25 -0500 Subject: [PATCH 4/4] refactor: make greeting a general Chat param, remove bookmark_on from public API greeting= now works regardless of whether client= is provided. bookmark_on is hardcoded to "response" internally. Removed the thin _setup_greeting wrapper; added docstrings for client and greeting parameters. --- pkg-py/src/shinychat/_chat.py | 33 +++++++++++++++++++--------- pkg-py/src/shinychat/_chat_client.py | 18 +++++---------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index cc532823..11f2be99 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -222,6 +222,21 @@ async def handle_user_input(user_input: str): id A unique identifier for the chat session. In Shiny Core, make sure this id matches a corresponding :func:`~shiny.ui.chat_ui` call in the UI. + client + A chatlas client (e.g., ``chatlas.ChatOpenAI()``). When provided, + streaming, cancellation, and bookmarking are wired up automatically. + The resulting :attr:`chat.client` exposes a + :class:`~shinychat.types.ChatClient` wrapper for swapping models + mid-session (``.set()``) and resetting the conversation (``.clear()``). + greeting + Content to display as a welcome message before any conversation. Can be + a string, :class:`~htmltools.HTML`, :class:`~htmltools.Tag`, + :class:`~htmltools.TagList`, :class:`~shinychat.chat_greeting`, or a + callable that returns one of those types. A callable greeting is invoked + when the chat is visible and empty; if the callable accepts a ``client`` + parameter (and ``client=`` was provided), a deep-copy of the chatlas + client with empty turns is passed so the greeting can be LLM-generated + without polluting conversation history. messages Deprecated. Use `chat.ui(messages=...)` instead. on_error @@ -245,7 +260,6 @@ def __init__( *, client: "chatlas.Chat[Any, Any] | None" = None, greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, - bookmark_on: "Optional[Literal['response']]" = "response", messages: Sequence[Any] = (), on_error: Literal["auto", "actual", "sanitize", "unhandled"] = "auto", tokenizer: TokenEncoding | None = None, @@ -375,24 +389,25 @@ async def _on_user_input(): self.client: "ChatClient | None" = None if client is not None: - self._setup_client(client, greeting=greeting, bookmark_on=bookmark_on) + self._setup_client(client) + + if greeting is not None: + from ._chat_client import setup_greeting + + setup_greeting(self, self.client, greeting, self._session) def _setup_client( self, client: "chatlas.Chat[Any, Any]", - *, - greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, - bookmark_on: "Optional[Literal['response']]" = "response", ) -> None: from chatlas import StreamController from shiny import reactive - from ._chat_client import ChatClient, setup_greeting + from ._chat_client import ChatClient chat_client = ChatClient( chat=self, client=client, - bookmark_on=bookmark_on, ) self.client = chat_client @@ -430,11 +445,9 @@ async def _on_stream_complete() -> None: self._effects.append(_on_cancel) self._effects.append(_on_stream_complete) - cancel_bm = self.enable_bookmarking(client, bookmark_on=bookmark_on) + cancel_bm = self.enable_bookmarking(client, bookmark_on="response") chat_client._cancel_bookmarking = cancel_bm - setup_greeting(self, chat_client, greeting, self._session) - @overload def on_user_submit(self, fn: UserSubmitFunction) -> Effect_: ... diff --git a/pkg-py/src/shinychat/_chat_client.py b/pkg-py/src/shinychat/_chat_client.py index 4bfa34ea..a4cfaf94 100644 --- a/pkg-py/src/shinychat/_chat_client.py +++ b/pkg-py/src/shinychat/_chat_client.py @@ -5,8 +5,6 @@ from typing import ( TYPE_CHECKING, Any, - Literal, - Optional, ) if TYPE_CHECKING: @@ -32,11 +30,9 @@ def __init__( *, chat: "Chat", client: "chatlas.Chat[Any, Any]", - bookmark_on: "Optional[Literal['response']]" = "response", ) -> None: self._chat = chat self._client = client - self._bookmark_on: Optional[Literal["response"]] = bookmark_on # (new_client, sync) stored when a swap is requested mid-stream self._pending_swap: tuple["chatlas.Chat[Any, Any]", bool] | None = None self._cancel_bookmarking: Any = None @@ -98,7 +94,7 @@ def _swap_client( self._cancel_bookmarking = None cancel = self._chat.enable_bookmarking( - new_client, bookmark_on=self._bookmark_on + new_client, bookmark_on="response" ) self._cancel_bookmarking = cancel @@ -172,18 +168,19 @@ def messages_to_turns( def setup_greeting( chat: "Chat", - chat_client: "ChatClient", + chat_client: "ChatClient | None", greeting: "str | HTML | Tag | TagList | ChatGreeting | Any | None", session: "Session", ) -> None: """ - Wire up greeting handling for a client-backed chat. + Wire up greeting handling for a chat. For static greetings (str / HTML / Tag / TagList / ChatGreeting), registers a reactive effect on ``{id}_greeting_requested`` that sends the greeting. For callable greetings the function is inspected for a ``client`` parameter. - If present, a deep-copy of the client with empty turns is passed to it. + If present (and a ``chat_client`` is available), a deep-copy of the client + with empty turns is passed to it. """ if greeting is None: return @@ -200,7 +197,7 @@ def setup_greeting( @reactive.effect @reactive.event(session.input[f"{chat.id}_greeting_requested"]) async def _on_greeting_requested() -> None: - if "client" in sig.parameters: + if "client" in sig.parameters and chat_client is not None: client_copy = copy.deepcopy(chat_client.value) client_copy.set_turns([]) result = fn(client=client_copy) @@ -210,13 +207,10 @@ async def _on_greeting_requested() -> None: if inspect.isawaitable(result): result = await result - # result is the greeting content from the callable await chat.set_greeting(result) # type: ignore[arg-type] chat._effects.append(_on_greeting_requested) else: - # Static greeting — wire up the greeting_requested input - # Cast: we've verified greeting is a static type (str/HTML/Tag/TagList/ChatGreeting) from htmltools import HTML, Tag, TagList from ._chat_types import ChatGreeting