diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index c6f10ad0..6b47ea4c 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -71,6 +71,18 @@ quartodoc: - Chat - chat_ui - chat_greeting + - title: Chat Module + options: + signature_name: relative + include_imports: false + include_inherited: false + include_attributes: true + include_classes: true + include_functions: true + contents: + - chat_mod_ui + - chat_mod_server + - ChatServerState - title: Shiny Express options: signature_name: relative diff --git a/pkg-py/docs/index.qmd b/pkg-py/docs/index.qmd index 8097c4d1..a6933392 100644 --- a/pkg-py/docs/index.qmd +++ b/pkg-py/docs/index.qmd @@ -121,6 +121,29 @@ Key points: - 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. - Partial responses are automatically preserved in chat history by chatlas when a stream is cancelled. +- If you use `chat_mod_server()`, cancellation is handled automatically — no manual wiring needed. + +## Chat module + +For Shiny Core apps that use [chatlas](https://posit-dev.github.io/chatlas/), `chat_mod_ui()` and `chat_mod_server()` provide a batteries-included chat interface that handles streaming, cancellation, and bookmarking automatically. + +```{.python file="app.py"} +from shiny import App, ui +from chatlas import ChatAnthropic +from shinychat import chat_mod_ui, chat_mod_server + +app_ui = ui.page_fillable( + chat_mod_ui("chat"), +) + +def server(input, output, session): + client = ChatAnthropic(system_prompt="You are a helpful assistant.") + chat = chat_mod_server("chat", client=client) + +app = App(app_ui, server) +``` + +The returned `ChatServerState` object provides reactive accessors like `chat.status()` and `chat.last_turn()`, along with methods like `chat.set_client()` for swapping models mid-session. See the [API reference](api/index.qmd) for the full interface. ## Learn more diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 34d9e89a..7afa80f1 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,10 +1,14 @@ from ._chat import Chat, chat_greeting, chat_ui +from ._chat_module import ChatServerState, chat_mod_server, chat_mod_ui from ._chat_normalize import message_content, message_content_chunk from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ "Chat", + "ChatServerState", "chat_greeting", + "chat_mod_server", + "chat_mod_ui", "chat_ui", "MarkdownStream", "output_markdown_stream", diff --git a/pkg-py/src/shinychat/_chat_module.py b/pkg-py/src/shinychat/_chat_module.py new file mode 100644 index 00000000..6f7cc669 --- /dev/null +++ b/pkg-py/src/shinychat/_chat_module.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +import copy +import inspect +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterable, + Callable, + Iterable, + Literal, + Optional, + Union, +) + +from htmltools import HTML, Tag, TagAttrValue, TagChild, TagList + +from ._chat import Chat, chat_ui +from ._chat_types import ( + ChatGreeting, + ChatMessage, + ChatMessageDict, +) + +if TYPE_CHECKING: + import chatlas + from shiny.ui.css import CssUnit + +__all__ = ( + "chat_mod_ui", + "chat_mod_server", + "ChatServerState", +) + + +def _messages_to_turns(messages: list[dict[str, str]]) -> list[Any]: + from chatlas import AssistantTurn, UserTurn + + turns: list[Any] = [] + for msg in messages: + role = msg.get("role", "assistant") + content = msg.get("content", "") + if role == "user": + turns.append(UserTurn(content)) + else: + turns.append(AssistantTurn(content)) + return turns + + +def chat_mod_ui( + id: str, + *, + messages: Optional[ + Iterable[Union[str, TagChild, ChatMessageDict, ChatMessage, Any]] + ] = None, + greeting: Optional[Union[str, HTML, Tag, TagList, ChatGreeting]] = None, + placeholder: str = "Enter a message...", + width: CssUnit = "min(680px, 100%)", + height: CssUnit = "auto", + fill: bool = True, + icon_assistant: Optional[Union[HTML, Tag, TagList]] = None, + footer: Optional[TagChild] = None, + **kwargs: TagAttrValue, +) -> Tag: + """ + UI for a batteries-included chat module. + + Use with :func:`~shinychat.chat_mod_server` to create a complete chat interface + that automatically wires a chatlas client to the chat UI. Stream cancellation is + enabled by default. + + Parameters + ---------- + id + The module ID. Must match the ``id`` passed to :func:`~shinychat.chat_mod_server`. + messages + Initial messages to display in the chat. + greeting + A static greeting to display at the top of the chat before any conversation + messages. Can be a markdown string or a :func:`~shinychat.chat_greeting` object. + For dynamic or streaming greetings, use the ``greeting`` parameter of + :func:`~shinychat.chat_mod_server` instead. + placeholder + Placeholder text for the chat input. + width + The width of the chat container. + height + The height of the chat container. + fill + Whether the chat should vertically take available space inside a fillable + container. + icon_assistant + The icon to use for assistant chat messages. + footer + Optional HTML content to display below the chat input. + kwargs + Additional attributes for the chat container element. + """ + from shiny.module import ResolvedId, resolve_id + + resolved = resolve_id(id) + return chat_ui( + ResolvedId(f"{resolved}-chat"), + messages=messages, + greeting=greeting, + enable_cancel=True, + placeholder=placeholder, + width=width, + height=height, + fill=fill, + icon_assistant=icon_assistant, + footer=footer, + **kwargs, # pyright: ignore[reportArgumentType] + ) + + +class ChatServerState: + """ + Return value from :func:`~shinychat.chat_mod_server`. + + Provides reactive access to the chat module state and methods to interact with + the chat programmatically. + """ + + def __init__( + self, + *, + _chat: Chat, + _last_input_rv: Any, + _last_turn_rv: Any, + _status_rv: Any, + _client_ref: list[Any], + _set_client_fn: Callable[..., None], + _clear_fn: Callable[..., Any], + _set_greeting_fn: Callable[..., Any], + ): + self._chat = _chat + self._last_input_rv = _last_input_rv + self._last_turn_rv = _last_turn_rv + self._status_rv = _status_rv + self._client_ref = _client_ref + self._set_client_fn = _set_client_fn + self._clear_fn = _clear_fn + self._set_greeting_fn = _set_greeting_fn + + def last_input(self) -> Optional[str]: + """ + Reactively read the last user input message. + """ + return self._last_input_rv() + + def last_turn(self) -> Optional[Any]: + """ + Reactively read the last assistant turn. + + Returns a ``chatlas.Turn`` object or ``None`` if no turn has completed. + """ + return self._last_turn_rv() + + def status(self) -> Literal["streaming", "idle"]: + """ + Reactively read the streaming status. + + Returns ``"streaming"`` while a response is being generated, or ``"idle"`` + otherwise. + """ + return self._status_rv() + + @property + def client(self) -> chatlas.Chat[Any, Any]: + """ + The current chatlas client. Not reactive — reading this in a reactive context + does not create a dependency. + """ + return self._client_ref[0] + + def update_user_input( + self, + value: Optional[str] = None, + *, + placeholder: Optional[str] = None, + submit: bool = False, + focus: bool = False, + ) -> None: + """ + Update the chat user input. + + Parameters + ---------- + value + The value to set in the user input box. + placeholder + New placeholder text. + submit + Whether to automatically submit the value. + focus + Whether to move focus to the input element. + """ + self._chat.update_user_input( + value=value, placeholder=placeholder, submit=submit, focus=focus + ) + + async def append( + self, + response: Any, + *, + role: Literal["assistant", "user"] = "assistant", + icon: Optional[Union[HTML, Tag, TagList]] = None, + ) -> None: + """ + Append a message or stream to the chat. + + Parameters + ---------- + response + A message string, HTML, or async iterable of chunks. + role + The role of the message (``"assistant"`` or ``"user"``). + icon + Optional icon to display next to the message. + """ + if isinstance(response, AsyncIterable): + stream_icon = icon if isinstance(icon, (HTML, Tag)) else None + await self._chat.append_message_stream(response, icon=stream_icon) + else: + msg: ChatMessageDict = { + "content": str(response) + if not isinstance(response, (HTML, Tag, TagList)) + else response, # type: ignore[typeddict-item] + "role": role, # type: ignore[typeddict-item] + } + await self._chat.append_message(msg, icon=icon) + + async def clear( + self, + messages: Optional[list[Union[dict[str, str], str]]] = None, + greeting: bool = False, + client_history: Literal["clear", "set", "append", "keep"] = "clear", + ) -> None: + """ + Clear the chat messages. + + Parameters + ---------- + messages + Optional list of messages to display after clearing. Each item should be a + dict with ``role`` and ``content`` keys, or a string (treated as an assistant + message). + greeting + If ``True``, also clear the greeting. If the module was created with a + greeting function, the function will be called again to generate a new + greeting. + client_history + How to handle the chatlas client's turn history: + + - ``"clear"``: wipe all turns (default). + - ``"set"``: replace turns with the ``messages`` provided. + - ``"append"``: append ``messages`` to the existing turns. + - ``"keep"``: leave the client turns unchanged. + """ + await self._clear_fn( + messages=messages, greeting=greeting, client_history=client_history + ) + + async def set_greeting( + self, + greeting: Union[str, HTML, Tag, TagList, ChatGreeting, None], + ) -> None: + """ + Set or clear the chat greeting. + + Parameters + ---------- + greeting + The greeting content. See :meth:`~shinychat.Chat.set_greeting` for details. + """ + await self._set_greeting_fn(greeting) + + def set_client( + self, new_client: chatlas.Chat[Any, Any], *, sync: bool = True + ) -> None: + """ + Replace the chatlas client. + + If the chat is currently streaming, the swap is deferred until the stream + completes. + + Parameters + ---------- + new_client + The new chatlas client to use. + sync + If ``True`` (the default), the new client's turn history, system prompt, + and tools are set to match the current client before swapping. + """ + self._set_client_fn(new_client, sync=sync) + + +def chat_mod_server( + id: str, + client: chatlas.Chat[Any, Any], + *, + greeting: Optional[ + Union[str, HTML, TagList, ChatGreeting, Callable[..., Any]] + ] = None, + bookmark_on_response: bool = True, +) -> ChatServerState: + """ + Server for a batteries-included chat module. + + Automatically wires a chatlas client to the chat UI created by + :func:`~shinychat.chat_mod_ui`, handling streaming, cancellation, bookmarking, + and optional greetings. + + Parameters + ---------- + id + The module ID. Must match the ``id`` passed to :func:`~shinychat.chat_mod_ui`. + client + A chatlas client (e.g., ``chatlas.ChatOpenAI()``) used to generate responses. + greeting + An optional greeting shown before any conversation messages. Accepts: + + - A static string, :class:`~htmltools.HTML`, :class:`~htmltools.TagList`, or + :func:`~shinychat.chat_greeting`. + - A callable (sync or async) that returns a greeting. The callable may + optionally accept a ``client`` argument; if so, a fresh clone of ``client`` + (with empty turn history) is passed to it. The callable is re-invoked each + time the chat needs a greeting (on first view and after ``clear()``). + bookmark_on_response + Whether to trigger a bookmark when the assistant finishes responding. + + Returns + ------- + : + A :class:`~shinychat.ChatServerState` instance with reactive accessors and + methods for interacting with the chat programmatically. + """ + try: + import chatlas as _chatlas # noqa: F401 + except ImportError as e: + raise ImportError( + "chatlas is required for chat_mod_server(). " + "Install it with: pip install chatlas" + ) from e + + from shiny import module, reactive + + @module.server + def _server( + input: Any, + output: Any, + session: Any, + client: Any, + greeting: Any, + bookmark_on_response: bool, + ) -> ChatServerState: + chat = Chat("chat") + client_ref: list[Any] = [client] + + _last_input: reactive.Value[Optional[str]] = reactive.Value(None) + _last_turn: reactive.Value[Any] = reactive.Value(None) + _status: reactive.Value[Literal["streaming", "idle"]] = reactive.Value( + "idle" + ) + _pending_swap: reactive.Value[Optional[dict[str, Any]]] = ( + reactive.Value(None) + ) + + def _swap_client(new_client: Any, sync: bool) -> None: + if sync: + new_client.set_turns(client_ref[0].get_turns()) + new_client.system_prompt = client_ref[0].system_prompt + new_client.set_tools(client_ref[0].get_tools()) + client_ref[0] = new_client + + with reactive.isolate(): + _re_enable_bookmarking() + + def _re_enable_bookmarking() -> None: + try: + chat.enable_bookmarking( + client_ref[0], + bookmark_on="response" if bookmark_on_response else None, + ) + except Exception: + pass + + def _set_client(new_client: Any, sync: bool = True) -> None: + with reactive.isolate(): + streaming = _status() == "streaming" + + if streaming: + _pending_swap.set({"client": new_client, "sync": sync}) + return + + _swap_client(new_client, sync) + + @chat.on_user_submit + async def _handle_submit(user_input: str) -> None: + _last_input.set(user_input) + _status.set("streaming") + response = await client_ref[0].stream_async( + user_input, content="all" + ) + await chat.append_message_stream(response) + + @reactive.effect + @reactive.event(lambda: input["chat_cancel"]) + def _on_cancel() -> None: + stream = chat.latest_message_stream + stream.cancel() + + @reactive.effect + def _on_stream_complete() -> None: + stream = chat.latest_message_stream + stream_status = stream.status() + + if stream_status == "success": + with reactive.isolate(): + _last_turn.set(client_ref[0].get_last_turn()) + _status.set("idle") + elif stream_status in ("error", "cancelled"): + _status.set("idle") + + with reactive.isolate(): + swap = _pending_swap() + current_status = _status() + + if swap is not None and current_status != "streaming": + _pending_swap.set(None) + _swap_client(swap["client"], swap["sync"]) + + async def _clear( + messages: Optional[list[Any]] = None, + greeting: bool = False, + client_history: Literal["clear", "set", "append", "keep"] = "clear", + ) -> None: + await chat.clear_messages(greeting=greeting) + + normalized: list[dict[str, str]] = [] + if messages is not None: + for msg in messages: + if isinstance(msg, str): + normalized.append({"role": "assistant", "content": msg}) + elif isinstance(msg, dict): + normalized.append( + { + "role": msg.get("role", "assistant"), + "content": str(msg.get("content", "")), + } + ) + else: + normalized.append( + {"role": "assistant", "content": str(msg)} + ) + + for msg in normalized: + await chat.append_message( + { + "content": msg["content"], + "role": msg["role"], + } + ) + + if client_history == "clear": + client_ref[0].set_turns([]) + elif client_history == "set": + client_ref[0].set_turns(_messages_to_turns(normalized)) + elif client_history == "append": + existing = client_ref[0].get_turns() + client_ref[0].set_turns( + existing + _messages_to_turns(normalized) + ) + # "keep" does nothing + + _last_turn.set(None) + _last_input.set(None) + + if callable(greeting) and not isinstance(greeting, (str, ChatGreeting)): + greeting_sig = inspect.signature(greeting) + greeting_params = list(greeting_sig.parameters.keys()) + + @reactive.effect + @reactive.event(lambda: input["chat_greeting_requested"]) + async def _on_greeting_requested() -> None: + args: dict[str, Any] = {} + if "client" in greeting_params: + greeter = copy.deepcopy(client_ref[0]) + greeter.set_turns([]) + args["client"] = greeter + + result: Any = greeting(**args) + if inspect.isawaitable(result): + result = await result + await chat.set_greeting(result) + + elif greeting is not None: + + @reactive.effect + async def _set_initial_greeting() -> None: + await chat.set_greeting(greeting) + _set_initial_greeting.destroy() # type: ignore[attr-defined] + + try: + chat.enable_bookmarking( + client_ref[0], + bookmark_on="response" if bookmark_on_response else None, + ) + except Exception: + pass + + return ChatServerState( + _chat=chat, + _last_input_rv=_last_input, + _last_turn_rv=_last_turn, + _status_rv=_status, + _client_ref=client_ref, + _set_client_fn=_set_client, + _clear_fn=_clear, + _set_greeting_fn=chat.set_greeting, + ) + + return _server( + id, + client=client, + greeting=greeting, + bookmark_on_response=bookmark_on_response, + ) diff --git a/pkg-py/tests/playwright/chat/chat_module/app.py b/pkg-py/tests/playwright/chat/chat_module/app.py new file mode 100644 index 00000000..697b89c0 --- /dev/null +++ b/pkg-py/tests/playwright/chat/chat_module/app.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any, Optional + +from shiny import App, Inputs, Outputs, Session, render, ui +from shinychat import chat_mod_server, chat_mod_ui + + +class MockChat: + def __init__(self): + self._turns: list[Any] = [] + self._system_prompt: str = "" + self._tools: list[Any] = [] + + async def stream_async(self, user_input: str, content: str = "all"): + async def _stream(): + for word in f"Echo: {user_input}".split(): + yield word + " " + + return _stream() + + def get_turns(self) -> list[Any]: + return self._turns + + def set_turns(self, turns: list[Any]) -> None: + self._turns = turns + + @property + def system_prompt(self) -> str: + return self._system_prompt + + @system_prompt.setter + def system_prompt(self, val: str) -> None: + self._system_prompt = val + + def get_tools(self) -> list[Any]: + return self._tools + + def set_tools(self, tools: list[Any]) -> None: + self._tools = tools + + def get_last_turn(self) -> Optional[Any]: + return None + + +app_ui = ui.page_fillable( + ui.panel_title("Chat Module Test"), + chat_mod_ui("chatmod"), + ui.output_text("status_out"), + fillable_mobile=True, +) + + +def server(input: Inputs, output: Outputs, session: Session): + client = MockChat() + state = chat_mod_server("chatmod", client=client) # pyright: ignore[reportArgumentType] + + @output + @render.text + def status_out(): + return state.status() + + +app = App(app_ui, server) diff --git a/pkg-py/tests/playwright/chat/chat_module/test_chat_mod_e2e.py b/pkg-py/tests/playwright/chat/chat_module/test_chat_mod_e2e.py new file mode 100644 index 00000000..dbc42bca --- /dev/null +++ b/pkg-py/tests/playwright/chat/chat_module/test_chat_mod_e2e.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from playwright.sync_api import Page, expect +from shiny.run import ShinyAppProc +from shinychat.playwright import ChatController + + +def test_chat_module_renders(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + +def test_chat_module_cancel_enabled( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + expect(chat.loc).to_have_attribute("enable-cancel", "") + + +def test_chat_module_submit_and_receive_response( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + chat.set_user_input("hello") + chat.send_user_input() + chat.expect_latest_message("Echo: hello ", timeout=30_000) + + +def test_chat_module_status_starts_idle( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + status = page.locator("#status_out") + expect(status).to_have_text("idle", timeout=10_000) + + +def test_chat_module_multiple_messages( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + chat = ChatController(page, "chatmod-chat") + expect(chat.loc).to_be_visible(timeout=30_000) + + chat.set_user_input("first") + chat.send_user_input() + chat.expect_latest_message("Echo: first ", timeout=30_000) + + chat.set_user_input("second") + chat.send_user_input() + chat.expect_latest_message("Echo: second ", timeout=30_000) diff --git a/pkg-py/tests/pytest/test_chat_mod.py b/pkg-py/tests/pytest/test_chat_mod.py new file mode 100644 index 00000000..ddf8ccf1 --- /dev/null +++ b/pkg-py/tests/pytest/test_chat_mod.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import inspect +from typing import cast + +import pytest +from htmltools import HTML, Tag, tags +from shiny import Session +from shiny.module import ResolvedId +from shiny.session import session_context +from shinychat import ChatServerState, chat_mod_ui + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +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 + + +test_session = cast(Session, _MockSession()) + + +# --------------------------------------------------------------------------- +# chat_mod_ui() tests +# --------------------------------------------------------------------------- + + +def test_chat_mod_ui_returns_tag(): + with session_context(test_session): + result = chat_mod_ui("mymod") + assert isinstance(result, Tag) + + +def test_chat_mod_ui_namespaced_id(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert 'id="mymod-chat"' in html + + +def test_chat_mod_ui_different_ids_are_distinct(): + with session_context(test_session): + result_a = chat_mod_ui("alpha") + result_b = chat_mod_ui("beta") + html_a = result_a.get_html_string() + html_b = result_b.get_html_string() + assert 'id="alpha-chat"' in html_a + assert 'id="beta-chat"' in html_b + assert 'id="beta-chat"' not in html_a + assert 'id="alpha-chat"' not in html_b + + +def test_chat_mod_ui_enable_cancel_attribute(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert "enable-cancel" in html + + +def test_chat_mod_ui_default_placeholder(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert "Enter a message..." in html + + +def test_chat_mod_ui_custom_placeholder(): + with session_context(test_session): + result = chat_mod_ui("mymod", placeholder="Ask me anything") + html = result.get_html_string() + assert "Ask me anything" in html + + +def test_chat_mod_ui_custom_width(): + with session_context(test_session): + result = chat_mod_ui("mymod", width="400px") + html = result.get_html_string() + assert "400px" in html + + +def test_chat_mod_ui_custom_height(): + with session_context(test_session): + result = chat_mod_ui("mymod", height="600px") + html = result.get_html_string() + assert "600px" in html + + +def test_chat_mod_ui_with_footer(): + footer = tags.div("footer content", id="my-footer") + with session_context(test_session): + result = chat_mod_ui("mymod", footer=footer) + html = result.get_html_string() + assert "footer content" in html + assert "my-footer" in html + + +def test_chat_mod_ui_with_tag_icon_assistant(): + icon = tags.span("bot", id="robot-icon") + with session_context(test_session): + result = chat_mod_ui("mymod", icon_assistant=icon) + html = result.get_html_string() + assert "robot-icon" in html + + +def test_chat_mod_ui_with_html_icon_assistant(): + icon = HTML("bot") + with session_context(test_session): + result = chat_mod_ui("mymod", icon_assistant=icon) + html = result.get_html_string() + assert "bot" in html + + +def test_chat_mod_ui_with_messages(): + messages = [{"role": "assistant", "content": "Hello there!"}] + with session_context(test_session): + result = chat_mod_ui("mymod", messages=messages) + html = result.get_html_string() + assert "Hello there!" in html + + +def test_chat_mod_ui_fill_true_by_default(): + with session_context(test_session): + result = chat_mod_ui("mymod") + html = result.get_html_string() + assert "fill" in html.lower() + + +def test_chat_mod_ui_fill_false(): + with session_context(test_session): + result = chat_mod_ui("mymod", fill=False) + html = result.get_html_string() + assert "html-fill-container" not in html + + +# --------------------------------------------------------------------------- +# ChatServerState structural tests +# --------------------------------------------------------------------------- + + +def test_chat_server_state_has_last_input_method(): + assert callable(getattr(ChatServerState, "last_input", None)) + + +def test_chat_server_state_has_last_turn_method(): + assert callable(getattr(ChatServerState, "last_turn", None)) + + +def test_chat_server_state_has_status_method(): + assert callable(getattr(ChatServerState, "status", None)) + + +def test_chat_server_state_client_is_property(): + members = dict( + inspect.getmembers(ChatServerState, lambda v: isinstance(v, property)) + ) + assert "client" in members + + +def test_chat_server_state_has_update_user_input_method(): + assert callable(getattr(ChatServerState, "update_user_input", None)) + + +def test_chat_server_state_has_append_method(): + method = getattr(ChatServerState, "append", None) + assert callable(method) + assert inspect.iscoroutinefunction(method) + + +def test_chat_server_state_has_clear_method(): + method = getattr(ChatServerState, "clear", None) + assert callable(method) + assert inspect.iscoroutinefunction(method) + + +def test_chat_server_state_has_set_greeting_method(): + method = getattr(ChatServerState, "set_greeting", None) + assert callable(method) + assert inspect.iscoroutinefunction(method) + + +def test_chat_server_state_has_set_client_method(): + assert callable(getattr(ChatServerState, "set_client", None)) + + +def test_chat_server_state_update_user_input_signature(): + sig = inspect.signature(ChatServerState.update_user_input) + params = list(sig.parameters.keys()) + assert "value" in params + assert "placeholder" in params + assert "submit" in params + assert "focus" in params + + +def test_chat_server_state_clear_signature(): + sig = inspect.signature(ChatServerState.clear) + params = list(sig.parameters.keys()) + assert "messages" in params + assert "greeting" in params + assert "client_history" in params + + +def test_chat_server_state_set_client_signature(): + sig = inspect.signature(ChatServerState.set_client) + params = list(sig.parameters.keys()) + assert "new_client" in params + assert "sync" in params + + +def test_chat_server_state_append_signature(): + sig = inspect.signature(ChatServerState.append) + params = list(sig.parameters.keys()) + assert "response" in params + assert "role" in params + assert "icon" in params + + +# --------------------------------------------------------------------------- +# Import tests +# --------------------------------------------------------------------------- + + +def test_import_chat_mod_ui(): + from shinychat import chat_mod_ui as _fn + + assert callable(_fn) + + +def test_import_chat_mod_server(): + from shinychat import chat_mod_server as _fn + + assert callable(_fn) + + +def test_import_chat_server_state(): + from shinychat import ChatServerState as _cls + + assert isinstance(_cls, type) + + +def test_chatlas_importable(): + """chatlas must be importable since chat_mod_server requires it.""" + try: + import chatlas # noqa: F401 + + chatlas_available = True + except ImportError: + chatlas_available = False + + if not chatlas_available: + pytest.skip("chatlas is not installed") + + assert chatlas_available