diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index d8ca82ad..6dbf6aeb 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## [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. + ## [0.4.0] - 2026-05-26 ### New features diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index c6f10ad0..060a20a7 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -69,6 +69,7 @@ quartodoc: include_functions: true contents: - Chat + - types.ChatClient - chat_ui - chat_greeting - title: Shiny Express diff --git a/pkg-py/docs/index.qmd b/pkg-py/docs/index.qmd index 8097c4d1..ee28464c 100644 --- a/pkg-py/docs/index.qmd +++ b/pkg-py/docs/index.qmd @@ -29,36 +29,48 @@ 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 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** ```{.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 -chat = Chat(id="chat") +client = ChatAnthropic(system_prompt="You are a helpful assistant.") +chat = Chat(id="chat", client=client) chat.ui() -# 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") ``` -To run the app, execute the following command in your terminal (or via the [Shiny extension](https://marketplace.visualstudio.com/items?itemName=Posit.shiny)): +**Shiny Core** -```bash -uv shiny run --reload app.py -``` +```{.python file="app.py"} +from shiny import App, ui +from chatlas import ChatAnthropic +from shinychat import Chat, chat_ui + +app_ui = ui.page_fillable( + chat_ui("chat", enable_cancel=True), +) +def server(input, output, session): + client = ChatAnthropic(system_prompt="You are a helpful assistant.") + chat = Chat("chat", client=client) + +app = App(app_ui, server, bookmark_store="url") +``` -## Stream cancellation +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. -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. +## Lower-level interface -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 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** diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index c13c0d57..11f2be99 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_client import ChatClient + else: chatlas = object @@ -220,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 @@ -241,6 +258,8 @@ def __init__( self, id: str, *, + client: "chatlas.Chat[Any, Any] | None" = None, + greeting: "str | HTML | Tag | TagList | ChatGreeting | Callable[..., Any] | None" = None, messages: Sequence[Any] = (), on_error: Literal["auto", "actual", "sanitize", "unhandled"] = "auto", tokenizer: TokenEncoding | None = None, @@ -368,6 +387,67 @@ 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) + + 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]", + ) -> None: + from chatlas import StreamController + from shiny import reactive + + from ._chat_client import ChatClient + + chat_client = ChatClient( + chat=self, + client=client, + ) + 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="response") + chat_client._cancel_bookmarking = cancel_bm + @overload def on_user_submit(self, fn: UserSubmitFunction) -> Effect_: ... @@ -1690,12 +1770,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]] @@ -1850,7 +1924,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: @@ -1887,7 +1961,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). @@ -1899,6 +1974,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, @@ -1908,7 +1989,7 @@ def ui( height=height, fill=fill, icon_assistant=icon_assistant, - enable_cancel=enable_cancel, + enable_cancel=resolved_cancel, footer=footer, **kwargs, ) diff --git a/pkg-py/src/shinychat/_chat_client.py b/pkg-py/src/shinychat/_chat_client.py new file mode 100644 index 00000000..a4cfaf94 --- /dev/null +++ b/pkg-py/src/shinychat/_chat_client.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import copy +import inspect +from typing import ( + TYPE_CHECKING, + Any, +) + +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]", + ) -> None: + self._chat = chat + self._client = client + # (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="response" + ) + 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 | None", + greeting: "str | HTML | Tag | TagList | ChatGreeting | Any | None", + session: "Session", +) -> None: + """ + 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 (and a ``chat_client`` is available), 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 and chat_client is not None: + 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 + + await chat.set_greeting(result) # type: ignore[arg-type] + + chat._effects.append(_on_greeting_requested) + else: + 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 c837edb1..8d9c1a6d 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_client import ChatClient from .._chat_types import ChatGreeting try: @@ -17,6 +18,7 @@ def __init__(self, *args, **kwargs): __all__ = [ + "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 new file mode 100644 index 00000000..616ada78 --- /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, chat_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_ui("chat", enable_cancel=True), + 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", 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_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..c29978ba --- /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, chat_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_ui("chat", enable_cancel=True), + ui.output_code("message_state"), +) + + +def server(input: Inputs, output: Outputs, session: Session) -> None: + client = MockChatClient() + chat = Chat("chat", client=client, greeting="Welcome!") # type: ignore[arg-type] + + @render.code + def message_state(): + 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 new file mode 100644 index 00000000..84da423d --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_core/test_chat_auto_core.py @@ -0,0 +1,49 @@ +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") + 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..e8dafdf0 --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_express/app.py @@ -0,0 +1,62 @@ +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", client=client, greeting="Welcome to the test!") # type: ignore[arg-type] +chat.ui() + + +"chat.messages():" + + +@render.code +def message_state(): + 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 new file mode 100644 index 00000000..b3851a71 --- /dev/null +++ b/pkg-py/tests/playwright/chat/auto_express/test_chat_auto_express.py @@ -0,0 +1,51 @@ +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) + + 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..77fe1792 --- /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} # type: ignore[return-value] + + 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..0f51413a --- /dev/null +++ b/pkg-py/tests/pytest/test_chat_auto.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import asyncio +import threading +from typing import Any, cast + +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, 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 + + 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()) + + +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] + + +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[Any]) -> None: + self._turns = list(turns) + + def get_tools(self) -> list[Any]: + return list(self._tools) + + def set_tools(self, tools: list[Any]) -> None: + self._tools = list(tools) + + +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", client=cast(Any, mock)) # type: ignore[arg-type] + return chat, mock + + +# --------------------------------------------------------------------------- +# ChatClient construction via Chat(client=) +# --------------------------------------------------------------------------- + + +def test_client_is_none_without_client(): + with session_context(test_session): + chat = Chat("test_no_client") + assert chat.client is None + + +def test_client_is_chat_client_with_client(): + chat, _ = make_chat() + assert isinstance(chat.client, ChatClient) + + +def test_client_value_returns_raw_client(): + chat, mock = make_chat() + assert chat.client is not None + assert chat.client.value is mock + + +# --------------------------------------------------------------------------- +# ChatClient._swap_client — sync / no-sync +# --------------------------------------------------------------------------- + + +def test_set_sync_copies_state(): + mock_old = MockClient( + turns=["turn1"], + system_prompt="be helpful", + tools=["tool_a"], + ) + mock_new = MockClient() + + with session_context(test_session): + chat = Chat("test_sync", client=cast(Any, mock_old)) # type: ignore[arg-type] + + 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_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): + 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 mock_new.get_turns() == [] + assert mock_new.system_prompt is None + assert mock_new.get_tools() == [] + + +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") + + with session_context(test_session): + chat = Chat("test_sp", client=cast(Any, mock_old)) # type: ignore[arg-type] + + 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" + + +# --------------------------------------------------------------------------- +# ChatClient.clear — validation +# --------------------------------------------------------------------------- + + +def test_clear_rejects_set_without_messages(): + chat, _ = make_chat() + assert chat.client is not None + + with pytest.raises(ValueError, match="messages.*must be provided"): + + 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(): + chat, _ = make_chat() + assert chat.client is not None + + with pytest.raises(ValueError, match="messages.*must be provided"): + + async def _run() -> None: + assert chat.client is not None + await chat.client.clear(client_history="append") + + _run_async(_run) + + +# --------------------------------------------------------------------------- +# messages_to_turns helper +# --------------------------------------------------------------------------- + + +def test_messages_to_turns_basic(): + msgs: list[ChatMessageDict] = [ + {"content": "hi", "role": "user"}, + {"content": "hello", "role": "assistant"}, + ] + turns = messages_to_turns(msgs) + assert len(turns) == 2 + assert turns[0].role == "user" + assert turns[1].role == "assistant" + + +def test_messages_to_turns_empty(): + assert messages_to_turns([]) == [] + + +def test_messages_to_turns_defaults_to_assistant(): + msgs: list[ChatMessageDict] = [{"content": "x", "role": "assistant"}] + turns = messages_to_turns(msgs) + assert turns[0].role == "assistant" + + +# --------------------------------------------------------------------------- +# chat_ui helpers +# --------------------------------------------------------------------------- + + +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_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 + + +# --------------------------------------------------------------------------- +# Public exports +# --------------------------------------------------------------------------- + + +def test_public_exports() -> None: + from shinychat.types import ChatClient as CC + + assert CC is ChatClient 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")