Skip to content

Commit 1b0e18a

Browse files
authored
feat(pkg-py): Python tool displays v2 (#107)
* feat(pkg-py): Python tool displays v2 * Cleanup * Python 3.9 support * chore: code lints * fix: type checks * fml * Update changelog * Require a ToolResultDisplay() for better typing, doc, and serialization experience * More faithful representation of TagChild that actually works with Pydantic * Python 3.9 strikes again * Update changelog
1 parent 8f5f76c commit 1b0e18a

16 files changed

+1059
-28
lines changed

pkg-py/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### New features
1111

12+
* New and improved UI for tool calls that occur via [chatlas](https://posit-dev.github.io/chatlas/). As a reminder, tool call displays are enabled by setting `content="all"` in chatlas' `.stream()` (or `.stream_async()`) method. See the tests under the `pkg-py/tests/playwright/tools` directory for inspiration of what is now possible with custom tool displays via the new `ToolResultDisplay` class. (#107)
1213
* Added new `message_content()` and `message_content_chunk()` generic (`singledispatch`) functions. These functions aren't intended to be called directly by users, but instead, provide an opportunity to teach `Chat.append_message()`/`Chat.append_message_stream()` to extract message contents from different types of objects. (#96)
1314

1415
### Bug fixes

pkg-py/src/shinychat/_chat.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
set_chatlas_state,
5555
)
5656
from ._chat_normalize import message_content, message_content_chunk
57+
from ._chat_normalize_chatlas import hide_corresponding_request, is_tool_result
5758
from ._chat_provider_types import (
5859
AnthropicMessage, # pyright: ignore[reportAttributeAccessIssue]
5960
GoogleMessage,
@@ -754,6 +755,9 @@ async def _append_message_chunk(
754755
# Normalize various message types into a ChatMessage()
755756
msg = message_content_chunk(message)
756757

758+
if is_tool_result(message):
759+
await hide_corresponding_request(message)
760+
757761
if operation == "replace":
758762
self._current_stream_message = (
759763
self._message_stream_checkpoint + msg.content

pkg-py/src/shinychat/_chat_normalize.py

Lines changed: 85 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
import sys
44
from functools import singledispatch
55

6-
from htmltools import HTML, Tagifiable
6+
from htmltools import HTML, Tagifiable, TagList
77

8+
from ._chat_normalize_chatlas import tool_request_contents, tool_result_contents
89
from ._chat_types import ChatMessage
910

1011
__all__ = ["message_content", "message_content_chunk"]
1112

1213

1314
@singledispatch
14-
def message_content(message) -> ChatMessage:
15+
def message_content(message):
1516
"""
1617
Extract content from various message types into a ChatMessage.
1718
@@ -42,7 +43,7 @@ def message_content(message) -> ChatMessage:
4243
If the message type is unsupported.
4344
"""
4445
if isinstance(message, (str, HTML)) or message is None:
45-
return ChatMessage(content=message, role="assistant")
46+
return ChatMessage(content=message)
4647
if isinstance(message, dict):
4748
if "content" not in message:
4849
raise ValueError("Message dictionary must have a 'content' key")
@@ -57,7 +58,7 @@ def message_content(message) -> ChatMessage:
5758

5859

5960
@singledispatch
60-
def message_content_chunk(chunk) -> ChatMessage:
61+
def message_content_chunk(chunk):
6162
"""
6263
Extract content from various message chunk types into a ChatMessage.
6364
@@ -88,7 +89,7 @@ def message_content_chunk(chunk) -> ChatMessage:
8889
If the chunk type is unsupported.
8990
"""
9091
if isinstance(chunk, (str, HTML)) or chunk is None:
91-
return ChatMessage(content=chunk, role="assistant")
92+
return ChatMessage(content=chunk)
9293
if isinstance(chunk, dict):
9394
if "content" not in chunk:
9495
raise ValueError("Chunk dictionary must have a 'content' key")
@@ -108,15 +109,71 @@ def message_content_chunk(chunk) -> ChatMessage:
108109

109110

110111
@message_content.register
111-
def _(message: Tagifiable) -> ChatMessage:
112-
return ChatMessage(content=message, role="assistant")
112+
def _(message: Tagifiable):
113+
return ChatMessage(content=message)
113114

114115

115116
@message_content_chunk.register
116-
def _(chunk: Tagifiable) -> ChatMessage:
117-
return ChatMessage(content=chunk, role="assistant")
117+
def _(chunk: Tagifiable):
118+
return ChatMessage(content=chunk)
118119

119120

121+
# -----------------------------------------------------------------
122+
# chatlas tool call display
123+
# -----------------------------------------------------------------
124+
try:
125+
from chatlas import ContentToolRequest, ContentToolResult, Turn
126+
from chatlas.types import Content, ContentText
127+
128+
@message_content.register
129+
def _(message: Content):
130+
return ChatMessage(content=str(message))
131+
132+
@message_content_chunk.register
133+
def _(chunk: Content):
134+
return message_content(chunk)
135+
136+
@message_content.register
137+
def _(message: ContentText):
138+
return ChatMessage(content=message.text)
139+
140+
@message_content_chunk.register
141+
def _(chunk: ContentText):
142+
return message_content(chunk)
143+
144+
@message_content.register
145+
def _(chunk: ContentToolRequest):
146+
return ChatMessage(content=tool_request_contents(chunk))
147+
148+
@message_content_chunk.register
149+
def _(chunk: ContentToolRequest):
150+
return message_content(chunk)
151+
152+
@message_content.register
153+
def _(chunk: ContentToolResult):
154+
return ChatMessage(content=tool_result_contents(chunk))
155+
156+
@message_content_chunk.register
157+
def _(chunk: ContentToolResult):
158+
return message_content(chunk)
159+
160+
@message_content.register
161+
def _(message: Turn):
162+
contents = TagList()
163+
for x in message.contents:
164+
contents.append(message_content(x).content)
165+
return ChatMessage(content=contents)
166+
167+
@message_content_chunk.register
168+
def _(chunk: Turn):
169+
return message_content(chunk)
170+
171+
# N.B., unlike R, Python Chat stores UI state and so can replay
172+
# it with additional workarounds. That's why R currently has a
173+
# shinychat_contents() method for Chat, but Python doesn't.
174+
except ImportError:
175+
pass
176+
120177
# ------------------------------------------------------------------
121178
# LangChain content extractor
122179
# ------------------------------------------------------------------
@@ -125,7 +182,7 @@ def _(chunk: Tagifiable) -> ChatMessage:
125182
from langchain_core.messages import BaseMessage, BaseMessageChunk
126183

127184
@message_content.register
128-
def _(message: BaseMessage) -> ChatMessage:
185+
def _(message: BaseMessage):
129186
if isinstance(message.content, list):
130187
raise ValueError(
131188
"The `message.content` provided seems to represent numerous messages. "
@@ -137,7 +194,7 @@ def _(message: BaseMessage) -> ChatMessage:
137194
)
138195

139196
@message_content_chunk.register
140-
def _(chunk: BaseMessageChunk) -> ChatMessage:
197+
def _(chunk: BaseMessageChunk):
141198
if isinstance(chunk.content, list):
142199
raise ValueError(
143200
"The `chunk.content` provided seems to represent numerous message chunks. "
@@ -159,14 +216,14 @@ def _(chunk: BaseMessageChunk) -> ChatMessage:
159216
from openai.types.chat import ChatCompletion, ChatCompletionChunk
160217

161218
@message_content.register
162-
def _(message: ChatCompletion) -> ChatMessage:
219+
def _(message: ChatCompletion):
163220
return ChatMessage(
164221
content=message.choices[0].message.content,
165222
role="assistant",
166223
)
167224

168225
@message_content_chunk.register
169-
def _(chunk: ChatCompletionChunk) -> ChatMessage:
226+
def _(chunk: ChatCompletionChunk):
170227
return ChatMessage(
171228
content=chunk.choices[0].delta.content,
172229
role="assistant",
@@ -185,21 +242,23 @@ def _(chunk: ChatCompletionChunk) -> ChatMessage:
185242
)
186243

187244
@message_content.register
188-
def _(message: AnthropicMessage) -> ChatMessage:
245+
def _(message: AnthropicMessage):
189246
content = message.content[0]
190247
if content.type != "text":
191248
raise ValueError(
192249
f"Anthropic message type {content.type} not supported. "
193250
"Only 'text' type is currently supported"
194251
)
195-
return ChatMessage(content=content.text, role="assistant")
252+
return ChatMessage(content=content.text)
196253

197254
# Old versions of singledispatch doesn't seem to support union types
198255
if sys.version_info >= (3, 11):
199-
from anthropic.types import RawMessageStreamEvent
256+
from anthropic.types import ( # pyright: ignore[reportMissingImports]
257+
RawMessageStreamEvent,
258+
)
200259

201260
@message_content_chunk.register
202-
def _(chunk: RawMessageStreamEvent) -> ChatMessage:
261+
def _(chunk: RawMessageStreamEvent):
203262
content = ""
204263
if chunk.type == "content_block_delta":
205264
if chunk.delta.type != "text_delta":
@@ -209,7 +268,7 @@ def _(chunk: RawMessageStreamEvent) -> ChatMessage:
209268
)
210269
content = chunk.delta.text
211270

212-
return ChatMessage(content=content, role="assistant")
271+
return ChatMessage(content=content)
213272
except ImportError:
214273
pass
215274

@@ -224,12 +283,12 @@ def _(chunk: RawMessageStreamEvent) -> ChatMessage:
224283
)
225284

226285
@message_content.register
227-
def _(message: GenerateContentResponse) -> ChatMessage:
228-
return ChatMessage(content=message.text, role="assistant")
286+
def _(message: GenerateContentResponse):
287+
return ChatMessage(content=message.text)
229288

230289
@message_content_chunk.register
231-
def _(chunk: GenerateContentResponse) -> ChatMessage:
232-
return ChatMessage(content=chunk.text, role="assistant")
290+
def _(chunk: GenerateContentResponse):
291+
return ChatMessage(content=chunk.text)
233292

234293
except ImportError:
235294
pass
@@ -243,14 +302,14 @@ def _(chunk: GenerateContentResponse) -> ChatMessage:
243302
from ollama import ChatResponse
244303

245304
@message_content.register
246-
def _(message: ChatResponse) -> ChatMessage:
305+
def _(message: ChatResponse):
247306
msg = message.message
248-
return ChatMessage(msg.content, role="assistant")
307+
return ChatMessage(msg.content)
249308

250309
@message_content_chunk.register
251-
def _(chunk: ChatResponse) -> ChatMessage:
310+
def _(chunk: ChatResponse):
252311
msg = chunk.message
253-
return ChatMessage(msg.content, role="assistant")
312+
return ChatMessage(msg.content)
254313

255314
except ImportError:
256315
pass

0 commit comments

Comments
 (0)