Skip to content
Closed
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
76 changes: 30 additions & 46 deletions pkg-py/src/shinychat/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 = (
"<thinking>\n"
+ self._current_stream_thinking
+ "\n</thinking>\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:
Expand All @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -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
),
Comment on lines +1617 to +1619
Copy link
Copy Markdown
Collaborator Author

@cpsievert cpsievert May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently this won't restore the correct content type. We already have this problem on main though, and I think it's worth us actually fixing this.

)

def _cancel_bookmarking():
_on_bookmark_client()
Expand Down Expand Up @@ -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()
12 changes: 11 additions & 1 deletion pkg-py/src/shinychat/_chat_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pkg-r/DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Imports:
bslib,
cli,
coro,
ellmer (>= 0.4.0.9000),
ellmer (>= 0.4.1.9000),
fastmap,
htmltools,
jsonlite,
Expand Down
4 changes: 4 additions & 0 deletions pkg-r/R/contents_shinychat.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading