Skip to content

feat(py): Add chat_mod_ui() and chat_mod_server() module functions#232

Draft
gadenbuie wants to merge 13 commits into
mainfrom
feat/144-py-module
Draft

feat(py): Add chat_mod_ui() and chat_mod_server() module functions#232
gadenbuie wants to merge 13 commits into
mainfrom
feat/144-py-module

Conversation

@gadenbuie
Copy link
Copy Markdown
Collaborator

Closes #144

Summary

Adds batteries-included module functions for Shiny Core apps that use chatlas. chat_mod_ui() + chat_mod_server() handle streaming, cancellation, bookmarking, and greetings automatically — replacing the manual wiring of Chat + chat_ui + on_user_submit + StreamController.

New public API

  • chat_mod_ui(id, ...) — thin wrapper around chat_ui() with namespaced ID, enable_cancel=True, and pass-through for greeting, messages, placeholder, width, height, fill, icon_assistant, footer.
  • chat_mod_server(id, client, ...) — module server that wires a chatlas client to the chat UI. Accepts static or dynamic greeting (sync or async callables, with optional client parameter for auto-cloned greeting clients). Returns a ChatServerState.
  • ChatServerState — return type with reactive accessors (last_input(), last_turn(), status()) and action methods (update_user_input(), append(), clear(), set_client(), set_greeting()). set_client() supports deferred swap during active streaming.

Files changed

File Purpose
pkg-py/src/shinychat/_chat_module.py New: chat_mod_ui(), chat_mod_server(), ChatServerState
pkg-py/src/shinychat/__init__.py Exports new public API
pkg-py/docs/_quarto.yml Adds "Chat Module" section to quartodoc API reference
pkg-py/docs/index.qmd Adds chat module section and cancellation note to Get Started page
pkg-py/tests/pytest/test_chat_module.py Unit tests for UI output, ChatServerState structure, imports
pkg-py/tests/playwright/chat/chat_module/ Playwright end-to-end test for streaming

Examples

Set ANTHROPIC_API_KEY before running.

Minimal — just chat_mod_ui() + chat_mod_server():

from chatlas import ChatAnthropic
from shiny import App, ui

from shinychat import chat_mod_server, chat_mod_ui

app_ui = ui.page_fillable(
    ui.card(
        ui.card_header("Minimal chat module"),
        chat_mod_ui("chat"),
    ),
)

def server(input, output, session):
    client = ChatAnthropic(
        model="claude-haiku-4-5",
        system_prompt="You are a helpful assistant. Be concise.",
    )
    chat_mod_server("chat", client=client)

app = App(app_ui, server)

Dynamic greeting — async greeting function with auto-cloned client:

async def make_greeting(client):
    stream = await client.stream_async(GREETING_PROMPT)
    return chat_greeting(stream)

chat = chat_mod_server("chat", client=client, greeting=make_greeting)

Client swap — switch models mid-session with set_client():

chat = chat_mod_server("chat", client=client)

@reactive.effect
@reactive.event(input.model)
def _swap():
    new_client = ChatAnthropic(model=MODELS[input.model()], system_prompt=SYSTEM_PROMPT)
    chat.set_client(new_client, sync=True)

gadenbuie added 10 commits May 26, 2026 13:58
Batteries-included module wrappers that wire a chatlas client to the
shinychat UI, mirroring the R package's `chat_mod_ui()`/`chat_mod_server()`.

Features:
- Automatic streaming (user submit -> stream_async -> append_message_stream)
- Cancel support via ExtendedTask.cancel()
- Reactive status(), last_input(), last_turn() accessors
- set_client() with deferred swap during streaming
- clear() with client_history management
- Greeting system (static, callable, callable with client clone)
- Bookmarking integration with re-registration on client swap

Closes #144
Prevents Shiny's ID validation from rejecting the hyphenated
"{module_id}-chat" string.
31 pytest unit tests covering chat_mod_ui() rendering, ChatServerState
interface structure, and imports.

5 Playwright integration tests covering UI rendering, cancel button,
message submission/response, status tracking, and multi-message flow.
…ference

Adds a new "Chat Module" section to the quartodoc config.
- Remove `bookmark_on_input` parameter (dead code; underlying
  Chat.enable_bookmarking() only supports response-triggered bookmarks)
- Implement `clear(client_history="set"/"append")` properly using
  chatlas UserTurn/AssistantTurn instead of no-op stubs
- Fix cancel button test to verify `enable-cancel` attribute on the
  container rather than asserting DOM presence of a conditionally-
  rendered element
`chatlas.Chat.stream_async()` is an async def returning an
AsyncGenerator, so it must be awaited. The missing await caused a
TypeError in `wrap_async_iterable` at runtime.
- Note cancel is enabled by default in chat_mod_ui()
- Fix last_turn() return type to Optional[Any]
- Note client property is not reactive
- Tighten append() role type to Literal["assistant", "user"]
- Tighten clear() messages type to list[Union[dict[str, str], str]]
- Replace internal input name with behavioral description in clear()
- Note async callable support in chat_mod_server() greeting param
Add a "Chat module" section showing chat_mod_ui/chat_mod_server usage
and a note that chat_mod_server handles cancellation automatically.
Passes static greeting values through to `chat_ui()`, matching
the R package where greeting flows through `...`.
@gadenbuie gadenbuie changed the title feat(py): Add chat_mod_ui() and chat_mod_server() module functions feat(py): Add chat_mod_ui() and chat_mod_server() module functions May 26, 2026
gadenbuie added 3 commits May 26, 2026 15:21
The pre-existing test_chat_module.py in chat/module/ collides with new
test files of the same name. Rename to test_chat_mod.py (unit) and
test_chat_mod_e2e.py (Playwright) so pytest can collect all three.
…ator

Matches the real chatlas.Chat.stream_async signature so that
`await client.stream_async(...)` works correctly in chat_mod_server.
@gadenbuie gadenbuie marked this pull request as ready for review May 26, 2026 19:34
@gadenbuie gadenbuie requested a review from cpsievert May 26, 2026 19:34
@cpsievert
Copy link
Copy Markdown
Collaborator

cpsievert commented May 26, 2026

Thanks for looking at this! I've been prototyping a parallel version on feat/chat-auto-module and landed on some ideas that deviate from the R implementation a bit more — I think Express support, the stateful Chat object, and Python naming conventions warrant it.

Express support. My branch adds a ChatExpress.ui_auto() method that creates the UI tag and wires the server in one call, returning a wrapper object (mirroring the return value from R's chat_mod_server()) with a tagify() method so Express auto-renders it. Without this, Express users have no path to the module — they'd have to drop into Core-style UI + server calls inside an Express app.

Supporting Express also inspired a different naming convention. In Express, the existing API is Chat.ui(), so Chat.ui_auto() is a natural extension. For Core, I carried that _auto prefix through to chat_auto_ui() / chat_auto_server() rather than _mod — in Python, "module" already means something specific, and _auto better communicates what these functions actually do (auto-wire a chatlas client to the chat UI). Open to other naming ideas though — the important thing is that it works naturally as both a standalone function and a method on ChatExpress.

This also drove a couple of downstream design choices:

  • No @module.server wrapper. Because Express mode needs the server wiring to happen inline (not inside a module function), I went with Chat(id) directly — same as the existing Chat + chat_ui API. This works fine because the chat's internal IDs are already scoped by the chat ID, and it keeps the mental model consistent: the ID you pass is the ID you get. R's chat_mod_server() works the same way.

  • State lives on the return object, not in module closures. Since there's no module scope to close over, the wrapper holds the client and pending-swap state as instance attributes. Status is derived from chat.latest_message_stream.status() rather than a separate reactive.Value — one fewer piece of state to keep in sync, and the stream is already the source of truth.

Exposing more of the Chat surface. My return type delegates messages(), append_message_stream(), message_stream_context(), and latest_message_stream from the underlying Chat. Without messages() in particular, users can't reactively read the conversation — last_turn() only gives the most recent one. And append_message_stream() matters for cases where the app streams content from something other than the wired client (tool outputs, system notifications, etc.).

Bookmarking error handling. The try/except Exception: pass around enable_bookmarking and _re_enable_bookmarking silently swallows real errors. I took a different approach — removed the ValueError that enable_bookmarking() raises when no bookmark store is configured (already on main as c820fd9). That way the call is safe unconditionally and you don't need the bare except.

Let me know if you'd like me to push my branch so you can compare side-by-side, or if you'd rather I send patches for any of these.

@gadenbuie
Copy link
Copy Markdown
Collaborator Author

Good point, I hadn't fully though out the implications of Express. Happy to defer this PR in favor of what you have cooking.

@gadenbuie gadenbuie marked this pull request as draft May 26, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[python] Add batteries-included module

2 participants