Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg-py/docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ quartodoc:
include_functions: true
contents:
- Chat
- types.ChatClient
- chat_ui
- chat_greeting
- title: Shiny Express
Expand Down
44 changes: 28 additions & 16 deletions pkg-py/docs/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
99 changes: 90 additions & 9 deletions pkg-py/src/shinychat/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
from shiny.types import Jsonifiable
from shiny.ui.css import CssUnit

from ._chat_client import ChatClient


else:
chatlas = object
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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_: ...

Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1887,7 +1961,8 @@ def ui(
button in place of the send button while streaming. You must observe
``input.<id>_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).
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down
Loading
Loading