diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index a90fe557..d4215a81 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -128,22 +128,9 @@ ChunkOption, Literal["append", "replace"], Union[str, None], - "Union[ContentType, None]", ] -def _is_content_thinking(msg: Any) -> bool: - """Check if a message is a ContentThinking object from chatlas.""" - try: - from chatlas.types import ( - ContentThinking, # pyright: ignore[reportAttributeAccessIssue] - ) - - return isinstance(msg, ContentThinking) - except ImportError: - return False - - class Chat: """ Create a chat interface. @@ -291,7 +278,6 @@ def __init__( # Chunked messages get accumulated (using this property) before changing state self._current_stream_message: str = "" - self._current_stream_thinking: str = "" self._current_stream_deps: list[HTMLDependency] = [] self._current_stream_id: str | None = None self._pending_messages: list[PendingMessage] = [] @@ -644,7 +630,7 @@ async def append_message( """ # If we're in a stream, queue the message if self._current_stream_id: - self._pending_messages.append((message, False, "append", None, None)) + self._pending_messages.append((message, False, "append", None)) return msg = message_content(message) @@ -654,6 +640,7 @@ async def append_message( self._store_message(msg) await self._send_append_message( message=msg, + content_type=resolve_content_type(message, msg.content), chunk=False, icon=icon, ) @@ -756,12 +743,11 @@ async def _append_message_chunk( stream_id: str, operation: Literal["append", "replace"] = "append", icon: HTML | Tag | TagList | None = None, - content_type_override: "ContentType | None" = None, ) -> None: # If currently we're in a *different* stream, queue the message chunk if self._current_stream_id and self._current_stream_id != stream_id: self._pending_messages.append( - (message, chunk, operation, stream_id, content_type_override) + (message, chunk, operation, stream_id) ) return @@ -820,16 +806,15 @@ async def _append_message_chunk( # Send the message to the client await self._send_append_message( message=msg, + content_type=resolve_content_type(message, msg.content), chunk=chunk, operation=operation, icon=icon, - content_type_override=content_type_override, ) finally: if chunk == "end": self._current_stream_id = None self._current_stream_message = "" - self._current_stream_thinking = "" self._current_stream_deps = [] self._message_stream_checkpoint = "" self._message_stream_deps_checkpoint = [] @@ -971,34 +956,16 @@ async def _append_message_stream( try: async for msg in message: - if _is_content_thinking(msg): - thinking_text = msg.thinking if hasattr(msg, "thinking") else str(msg) - self._current_stream_thinking += thinking_text - thinking_msg = ChatMessage(content=thinking_text, role="assistant") - await self._send_append_message( - thinking_msg, - chunk=True, - content_type_override="thinking", - ) - continue - await self._append_message_chunk(msg, chunk=True, stream_id=id) return self._current_stream_message finally: - if self._current_stream_thinking: - self._current_stream_message = ( - "\n" - + self._current_stream_thinking - + "\n\n\n" - + self._current_stream_message - ) await self._append_message_chunk(empty, chunk="end", stream_id=id) await self._flush_pending_messages() async def _flush_pending_messages(self): pending = self._pending_messages self._pending_messages = [] - for msg, chunk, operation, stream_id, content_type_override in pending: + for msg, chunk, operation, stream_id in pending: if chunk is False: await self.append_message(msg) else: @@ -1007,17 +974,16 @@ async def _flush_pending_messages(self): chunk=chunk, operation=operation, stream_id=cast(str, stream_id), - content_type_override=content_type_override, ) # Send a message to the UI async def _send_append_message( self, message: StoredMessage | ChatMessage, + content_type: "ContentType", chunk: ChunkOption = False, operation: Literal["append", "replace"] = "append", icon: HTML | Tag | TagList | None = None, - content_type_override: "ContentType | None" = None, ): message = self._as_stored_message(message) @@ -1026,11 +992,6 @@ async def _send_append_message( return content = message.content - content_type: ContentType = ( - content_type_override - if content_type_override is not None - else "html" if isinstance(content, HTML) else "markdown" - ) msg_payload: MessagePayload = { "role": message.role, @@ -1651,7 +1612,12 @@ async def _on_restore_ui(state: RestoreState): html_deps=message_dict.get("html_deps"), ) self._store_message(stored) - await self._send_append_message(stored) + await self._send_append_message( + stored, + content_type=resolve_content_type( + message_dict, stored.content + ), + ) def _cancel_bookmarking(): _on_bookmark_client() @@ -1934,4 +1900,22 @@ def is_tool_result(val: object) -> "TypeGuard[chatlas.ContentToolResult]": return False +def is_content_thinking(msg: object) -> bool: + try: + from chatlas.types import ( + ContentThinking, # pyright: ignore[reportAttributeAccessIssue] + ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] + ) + + return isinstance(msg, (ContentThinking, ContentThinkingDelta)) + except ImportError: + return False + + +def resolve_content_type(message: object, content: object) -> "ContentType": + if is_content_thinking(message): + return "thinking" + return "html" if isinstance(content, HTML) else "markdown" + + CHAT_INSTANCES: WeakValueDictionary[str, Chat] = WeakValueDictionary() diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index b2d09c0b..a6d7f9c5 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -166,11 +166,13 @@ def _(chunk: ContentToolResult): def _(chunk: ContentToolResult): return message_content(chunk) - # ContentThinking is handled directly in _append_message_stream, + # ContentThinking appears in non-streaming contexts (e.g., bookmark restore). + # ContentThinkingDelta is handled directly in _append_message_stream, # but register it here so message_content_chunk doesn't raise for it. try: from chatlas.types import ( ContentThinking, # pyright: ignore[reportAttributeAccessIssue] + ContentThinkingDelta, # pyright: ignore[reportAttributeAccessIssue] ) @message_content.register @@ -180,6 +182,14 @@ def _(message: ContentThinking): @message_content_chunk.register def _(chunk: ContentThinking): return ChatMessage(content=chunk.thinking) + + @message_content.register + def _(message: ContentThinkingDelta): + return ChatMessage(content=message.thinking) + + @message_content_chunk.register + def _(chunk: ContentThinkingDelta): + return ChatMessage(content=chunk.thinking) except ImportError: pass diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index 195f012b..3b2c4fb2 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -24,7 +24,7 @@ Imports: bslib, cli, coro, - ellmer (>= 0.4.0.9000), + ellmer (>= 0.4.1.9000), fastmap, htmltools, jsonlite, diff --git a/pkg-r/R/contents_shinychat.R b/pkg-r/R/contents_shinychat.R index 50e98c68..646c9a56 100644 --- a/pkg-r/R/contents_shinychat.R +++ b/pkg-r/R/contents_shinychat.R @@ -140,6 +140,10 @@ S7::method(contents_shinychat, ellmer::ContentThinking) <- function(content) { structure(content@thinking, class = "shinychat_thinking") } +S7::method(contents_shinychat, ellmer::ContentThinkingDelta) <- function(content) { + structure(content@thinking, class = "shinychat_thinking") +} + new_tool_card <- function(type, request_id, tool_name, ...) { type <- arg_match(type, c("request", "result")) diff --git a/pyproject.toml b/pyproject.toml index be0730a9..30fb44a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ Changelog = "https://github.com/posit-dev/shinychat/blob/main/pkg-py/CHANGELOG.m [project.optional-dependencies] providers = [ "anthropic;python_version>='3.11'", - "chatlas[mcp]>=0.15.0", + "chatlas[mcp]>=0.16.0", "pydantic", "google-genai", "langchain-core>=1.0.0",