shinychat 0.0.1a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shinychat/__init__.py +3 -0
- shinychat/__version.py +21 -0
- shinychat/_chat.py +1878 -0
- shinychat/_chat_bookmark.py +110 -0
- shinychat/_chat_normalize.py +350 -0
- shinychat/_chat_provider_types.py +127 -0
- shinychat/_chat_tokenizer.py +67 -0
- shinychat/_chat_types.py +79 -0
- shinychat/_html_deps_py_shiny.py +41 -0
- shinychat/_markdown_stream.py +374 -0
- shinychat/_typing_extensions.py +63 -0
- shinychat/_utils.py +173 -0
- shinychat/express/__init__.py +3 -0
- shinychat/playwright/__init__.py +3 -0
- shinychat/playwright/_chat.py +154 -0
- shinychat/www/GIT_VERSION +1 -0
- shinychat/www/chat/chat.css +2 -0
- shinychat/www/chat/chat.css.map +7 -0
- shinychat/www/chat/chat.js +87 -0
- shinychat/www/chat/chat.js.map +7 -0
- shinychat/www/markdown-stream/markdown-stream.css +2 -0
- shinychat/www/markdown-stream/markdown-stream.css.map +7 -0
- shinychat/www/markdown-stream/markdown-stream.js +149 -0
- shinychat/www/markdown-stream/markdown-stream.js.map +7 -0
- shinychat-0.0.1a0.dist-info/METADATA +36 -0
- shinychat-0.0.1a0.dist-info/RECORD +28 -0
- shinychat-0.0.1a0.dist-info/WHEEL +4 -0
- shinychat-0.0.1a0.dist-info/licenses/LICENSE +21 -0
shinychat/_chat.py
ADDED
@@ -0,0 +1,1878 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import inspect
|
4
|
+
from contextlib import asynccontextmanager
|
5
|
+
from typing import (
|
6
|
+
TYPE_CHECKING,
|
7
|
+
Any,
|
8
|
+
AsyncIterable,
|
9
|
+
Awaitable,
|
10
|
+
Callable,
|
11
|
+
Iterable,
|
12
|
+
Literal,
|
13
|
+
Optional,
|
14
|
+
Sequence,
|
15
|
+
Tuple,
|
16
|
+
Union,
|
17
|
+
cast,
|
18
|
+
overload,
|
19
|
+
)
|
20
|
+
from weakref import WeakValueDictionary
|
21
|
+
|
22
|
+
from htmltools import (
|
23
|
+
HTML,
|
24
|
+
RenderedHTML,
|
25
|
+
Tag,
|
26
|
+
TagAttrValue,
|
27
|
+
TagChild,
|
28
|
+
TagList,
|
29
|
+
css,
|
30
|
+
)
|
31
|
+
from shiny import reactive
|
32
|
+
from shiny._deprecated import warn_deprecated
|
33
|
+
from shiny.bookmark import BookmarkState, RestoreState
|
34
|
+
from shiny.bookmark._types import BookmarkStore
|
35
|
+
from shiny.module import ResolvedId, resolve_id
|
36
|
+
from shiny.reactive._reactives import Effect_
|
37
|
+
from shiny.session import (
|
38
|
+
get_current_session,
|
39
|
+
require_active_session,
|
40
|
+
session_context,
|
41
|
+
)
|
42
|
+
from shiny.types import MISSING, MISSING_TYPE, Jsonifiable, NotifyException
|
43
|
+
from shiny.ui.css import CssUnit, as_css_unit
|
44
|
+
from shiny.ui.fill import as_fill_item, as_fillable_container
|
45
|
+
|
46
|
+
from . import _utils
|
47
|
+
from ._chat_bookmark import (
|
48
|
+
BookmarkCancelCallback,
|
49
|
+
CancelCallback,
|
50
|
+
ClientWithState,
|
51
|
+
get_chatlas_state,
|
52
|
+
is_chatlas_chat_client,
|
53
|
+
set_chatlas_state,
|
54
|
+
)
|
55
|
+
from ._chat_normalize import normalize_message, normalize_message_chunk
|
56
|
+
from ._chat_provider_types import (
|
57
|
+
AnthropicMessage,
|
58
|
+
GoogleMessage,
|
59
|
+
LangChainMessage,
|
60
|
+
OllamaMessage,
|
61
|
+
OpenAIMessage,
|
62
|
+
ProviderMessage,
|
63
|
+
ProviderMessageFormat,
|
64
|
+
as_provider_message,
|
65
|
+
)
|
66
|
+
from ._chat_tokenizer import (
|
67
|
+
TokenEncoding,
|
68
|
+
TokenizersEncoding,
|
69
|
+
get_default_tokenizer,
|
70
|
+
)
|
71
|
+
from ._chat_types import (
|
72
|
+
ChatMessage,
|
73
|
+
ChatMessageDict,
|
74
|
+
ClientMessage,
|
75
|
+
TransformedMessage,
|
76
|
+
)
|
77
|
+
from ._html_deps_py_shiny import chat_deps
|
78
|
+
|
79
|
+
if TYPE_CHECKING:
|
80
|
+
import chatlas
|
81
|
+
|
82
|
+
else:
|
83
|
+
chatlas = object
|
84
|
+
|
85
|
+
__all__ = (
|
86
|
+
"Chat",
|
87
|
+
"ChatExpress",
|
88
|
+
"chat_ui",
|
89
|
+
"ChatMessageDict",
|
90
|
+
)
|
91
|
+
|
92
|
+
|
93
|
+
# TODO: UserInput might need to be a list of dicts if we want to support multiple
|
94
|
+
# user input content types
|
95
|
+
TransformUserInput = Callable[[str], Union[str, None]]
|
96
|
+
TransformUserInputAsync = Callable[[str], Awaitable[Union[str, None]]]
|
97
|
+
TransformAssistantResponse = Callable[[str], Union[str, HTML, None]]
|
98
|
+
TransformAssistantResponseAsync = Callable[
|
99
|
+
[str], Awaitable[Union[str, HTML, None]]
|
100
|
+
]
|
101
|
+
TransformAssistantResponseChunk = Callable[
|
102
|
+
[str, str, bool], Union[str, HTML, None]
|
103
|
+
]
|
104
|
+
TransformAssistantResponseChunkAsync = Callable[
|
105
|
+
[str, str, bool], Awaitable[Union[str, HTML, None]]
|
106
|
+
]
|
107
|
+
TransformAssistantResponseFunction = Union[
|
108
|
+
TransformAssistantResponse,
|
109
|
+
TransformAssistantResponseAsync,
|
110
|
+
TransformAssistantResponseChunk,
|
111
|
+
TransformAssistantResponseChunkAsync,
|
112
|
+
]
|
113
|
+
UserSubmitFunction0 = Union[
|
114
|
+
Callable[[], None],
|
115
|
+
Callable[[], Awaitable[None]],
|
116
|
+
]
|
117
|
+
UserSubmitFunction1 = Union[
|
118
|
+
Callable[[str], None],
|
119
|
+
Callable[[str], Awaitable[None]],
|
120
|
+
]
|
121
|
+
UserSubmitFunction = Union[
|
122
|
+
UserSubmitFunction0,
|
123
|
+
UserSubmitFunction1,
|
124
|
+
]
|
125
|
+
|
126
|
+
ChunkOption = Literal["start", "end", True, False]
|
127
|
+
|
128
|
+
PendingMessage = Tuple[
|
129
|
+
Any,
|
130
|
+
ChunkOption,
|
131
|
+
Literal["append", "replace"],
|
132
|
+
Union[str, None],
|
133
|
+
]
|
134
|
+
|
135
|
+
|
136
|
+
class Chat:
|
137
|
+
"""
|
138
|
+
Create a chat interface.
|
139
|
+
|
140
|
+
A UI component for building conversational interfaces. With it, end users can submit
|
141
|
+
messages, which will cause a `.on_user_submit()` callback to run. That callback gets
|
142
|
+
passed the user input message, which can be used to generate a response. The
|
143
|
+
response can then be appended to the chat using `.append_message()` or
|
144
|
+
`.append_message_stream()`.
|
145
|
+
|
146
|
+
Here's a rough outline for how to implement a `Chat`:
|
147
|
+
|
148
|
+
```python
|
149
|
+
from shiny.express import ui
|
150
|
+
|
151
|
+
# Create and display chat instance
|
152
|
+
chat = ui.Chat(id="my_chat")
|
153
|
+
chat.ui()
|
154
|
+
|
155
|
+
|
156
|
+
# Define a callback to run when the user submits a message
|
157
|
+
@chat.on_user_submit
|
158
|
+
async def handle_user_input(user_input: str):
|
159
|
+
# Create a response message stream
|
160
|
+
response = await my_model.generate_response(user_input, stream=True)
|
161
|
+
# Append the response into the chat
|
162
|
+
await chat.append_message_stream(response)
|
163
|
+
```
|
164
|
+
|
165
|
+
In the outline above, `my_model.generate_response()` is a placeholder for
|
166
|
+
the function that generates a response based on the chat's messages. This function
|
167
|
+
will look different depending on the model you're using, but it will generally
|
168
|
+
involve passing the messages to the model and getting a response back. Also, you'll
|
169
|
+
typically have a choice to `stream=True` the response generation, and in that case,
|
170
|
+
you'll use `.append_message_stream()` instead of `.append_message()` to append the
|
171
|
+
response to the chat. Streaming is preferrable when available since it allows for
|
172
|
+
more responsive and scalable chat interfaces.
|
173
|
+
|
174
|
+
It is also highly recommended to use a package like
|
175
|
+
[chatlas](https://posit-dev.github.io/chatlas/) to generate responses, especially
|
176
|
+
when responses should be aware of the chat history, support tool calls, etc.
|
177
|
+
See this [article](https://posit-dev.github.io/chatlas/web-apps.html) to learn more.
|
178
|
+
|
179
|
+
Parameters
|
180
|
+
----------
|
181
|
+
id
|
182
|
+
A unique identifier for the chat session. In Shiny Core, make sure this id
|
183
|
+
matches a corresponding :func:`~shiny.ui.chat_ui` call in the UI.
|
184
|
+
messages
|
185
|
+
A sequence of messages to display in the chat. A given message can be one of the
|
186
|
+
following:
|
187
|
+
|
188
|
+
* A string, which is interpreted as markdown and rendered to HTML on the client.
|
189
|
+
* To prevent interpreting as markdown, mark the string as
|
190
|
+
:class:`~shiny.ui.HTML`.
|
191
|
+
* A UI element (specifically, a :class:`~shiny.ui.TagChild`).
|
192
|
+
* This includes :class:`~shiny.ui.TagList`, which take UI elements
|
193
|
+
(including strings) as children. In this case, strings are still
|
194
|
+
interpreted as markdown as long as they're not inside HTML.
|
195
|
+
* A dictionary with `content` and `role` keys. The `content` key can contain a
|
196
|
+
content as described above, and the `role` key can be "assistant" or "user".
|
197
|
+
|
198
|
+
**NOTE:** content may include specially formatted **input suggestion** links
|
199
|
+
(see `.append_message()` for more information).
|
200
|
+
on_error
|
201
|
+
How to handle errors that occur in response to user input. When `"unhandled"`,
|
202
|
+
the app will stop running when an error occurs. Otherwise, a notification
|
203
|
+
is displayed to the user and the app continues to run.
|
204
|
+
|
205
|
+
* `"auto"`: Sanitize the error message if the app is set to sanitize errors,
|
206
|
+
otherwise display the actual error message.
|
207
|
+
* `"actual"`: Display the actual error message to the user.
|
208
|
+
* `"sanitize"`: Sanitize the error message before displaying it to the user.
|
209
|
+
* `"unhandled"`: Do not display any error message to the user.
|
210
|
+
tokenizer
|
211
|
+
The tokenizer to use for calculating token counts, which is required to impose
|
212
|
+
`token_limits` in `.messages()`. If not provided, a default generic tokenizer
|
213
|
+
is attempted to be loaded from the tokenizers library. A specific tokenizer
|
214
|
+
may also be provided by following the `TokenEncoding` (tiktoken or tozenizers)
|
215
|
+
protocol (e.g., `tiktoken.encoding_for_model("gpt-4o")`).
|
216
|
+
"""
|
217
|
+
|
218
|
+
def __init__(
|
219
|
+
self,
|
220
|
+
id: str,
|
221
|
+
*,
|
222
|
+
messages: Sequence[Any] = (),
|
223
|
+
on_error: Literal["auto", "actual", "sanitize", "unhandled"] = "auto",
|
224
|
+
tokenizer: TokenEncoding | None = None,
|
225
|
+
):
|
226
|
+
if not isinstance(id, str):
|
227
|
+
raise TypeError("`id` must be a string.")
|
228
|
+
|
229
|
+
self.id = resolve_id(id)
|
230
|
+
self.user_input_id = ResolvedId(f"{self.id}_user_input")
|
231
|
+
self._transform_user: TransformUserInputAsync | None = None
|
232
|
+
self._transform_assistant: (
|
233
|
+
TransformAssistantResponseChunkAsync | None
|
234
|
+
) = None
|
235
|
+
self._tokenizer = tokenizer
|
236
|
+
|
237
|
+
# TODO: remove the `None` when this PR lands:
|
238
|
+
# https://github.com/posit-dev/py-shiny/pull/793/files
|
239
|
+
self._session = require_active_session(None)
|
240
|
+
|
241
|
+
# Default to sanitizing until we know the app isn't sanitizing errors
|
242
|
+
if on_error == "auto":
|
243
|
+
on_error = "sanitize"
|
244
|
+
app = self._session.app
|
245
|
+
if app is not None and not app.sanitize_errors: # type: ignore
|
246
|
+
on_error = "actual"
|
247
|
+
|
248
|
+
self.on_error = on_error
|
249
|
+
|
250
|
+
# Chunked messages get accumulated (using this property) before changing state
|
251
|
+
self._current_stream_message: str = ""
|
252
|
+
self._current_stream_id: str | None = None
|
253
|
+
self._pending_messages: list[PendingMessage] = []
|
254
|
+
|
255
|
+
# For tracking message stream state when entering/exiting nested streams
|
256
|
+
self._message_stream_checkpoint: str = ""
|
257
|
+
|
258
|
+
# If a user input message is transformed into a response, we need to cancel
|
259
|
+
# the next user input submit handling
|
260
|
+
self._suspend_input_handler: bool = False
|
261
|
+
|
262
|
+
# Keep track of effects so we can destroy them when the chat is destroyed
|
263
|
+
self._effects: list[Effect_] = []
|
264
|
+
self._cancel_bookmarking_callbacks: CancelCallback | None = None
|
265
|
+
|
266
|
+
# Initialize chat state and user input effect
|
267
|
+
with session_context(self._session):
|
268
|
+
# Initialize message state
|
269
|
+
self._messages: reactive.Value[tuple[TransformedMessage, ...]] = (
|
270
|
+
reactive.Value(())
|
271
|
+
)
|
272
|
+
|
273
|
+
self._latest_user_input: reactive.Value[
|
274
|
+
TransformedMessage | None
|
275
|
+
] = reactive.Value(None)
|
276
|
+
|
277
|
+
@reactive.extended_task
|
278
|
+
async def _mock_task() -> str:
|
279
|
+
return ""
|
280
|
+
|
281
|
+
self._latest_stream: reactive.Value[
|
282
|
+
reactive.ExtendedTask[[], str]
|
283
|
+
] = reactive.Value(_mock_task)
|
284
|
+
|
285
|
+
# TODO: deprecate messages once we start promoting managing LLM message
|
286
|
+
# state through other means
|
287
|
+
async def _append_init_messages():
|
288
|
+
for msg in messages:
|
289
|
+
await self.append_message(msg)
|
290
|
+
|
291
|
+
@reactive.effect
|
292
|
+
async def _init_chat():
|
293
|
+
await _append_init_messages()
|
294
|
+
|
295
|
+
self._append_init_messages = _append_init_messages
|
296
|
+
self._init_chat = _init_chat
|
297
|
+
|
298
|
+
# When user input is submitted, transform, and store it in the chat state
|
299
|
+
# (and make sure this runs before other effects since when the user
|
300
|
+
# calls `.messages()`, they should get the latest user input)
|
301
|
+
@reactive.effect(priority=9999)
|
302
|
+
@reactive.event(self._user_input)
|
303
|
+
async def _on_user_input():
|
304
|
+
msg = ChatMessage(content=self._user_input(), role="user")
|
305
|
+
# It's possible that during the transform, a message is appended, so get
|
306
|
+
# the length now, so we can insert the new message at the right index
|
307
|
+
n_pre = len(self._messages())
|
308
|
+
msg_post = await self._transform_message(msg)
|
309
|
+
if msg_post is not None:
|
310
|
+
self._store_message(msg_post)
|
311
|
+
self._suspend_input_handler = False
|
312
|
+
else:
|
313
|
+
# A transformed value of None is a special signal to suspend input
|
314
|
+
# handling (i.e., don't generate a response)
|
315
|
+
self._store_message(msg, index=n_pre)
|
316
|
+
await self._remove_loading_message()
|
317
|
+
self._suspend_input_handler = True
|
318
|
+
|
319
|
+
self._effects.append(_init_chat)
|
320
|
+
self._effects.append(_on_user_input)
|
321
|
+
|
322
|
+
# Prevent repeated calls to Chat() with the same id from accumulating effects
|
323
|
+
instance_id = self.id + "_session" + self._session.id
|
324
|
+
instance = CHAT_INSTANCES.pop(instance_id, None)
|
325
|
+
if instance is not None:
|
326
|
+
instance.destroy()
|
327
|
+
CHAT_INSTANCES[instance_id] = self
|
328
|
+
|
329
|
+
@overload
|
330
|
+
def on_user_submit(self, fn: UserSubmitFunction) -> Effect_: ...
|
331
|
+
|
332
|
+
@overload
|
333
|
+
def on_user_submit(
|
334
|
+
self,
|
335
|
+
) -> Callable[[UserSubmitFunction], Effect_]: ...
|
336
|
+
|
337
|
+
def on_user_submit(
|
338
|
+
self, fn: UserSubmitFunction | None = None
|
339
|
+
) -> Effect_ | Callable[[UserSubmitFunction], Effect_]:
|
340
|
+
"""
|
341
|
+
Define a function to invoke when user input is submitted.
|
342
|
+
|
343
|
+
Apply this method as a decorator to a function (`fn`) that should be invoked
|
344
|
+
when the user submits a message. This function can take an optional argument,
|
345
|
+
which will be the user input message.
|
346
|
+
|
347
|
+
In many cases, the implementation of `fn` should also do the following:
|
348
|
+
|
349
|
+
1. Generate a response based on the user input.
|
350
|
+
* If the response should be aware of chat history, use a package
|
351
|
+
like [chatlas](https://posit-dev.github.io/chatlas/) to manage the chat
|
352
|
+
state, or use the `.messages()` method to get the chat history.
|
353
|
+
2. Append that response to the chat component using `.append_message()` ( or
|
354
|
+
`.append_message_stream()` if the response is streamed).
|
355
|
+
|
356
|
+
Parameters
|
357
|
+
----------
|
358
|
+
fn
|
359
|
+
A function to invoke when user input is submitted.
|
360
|
+
|
361
|
+
Note
|
362
|
+
----
|
363
|
+
This method creates a reactive effect that only gets invalidated when the user
|
364
|
+
submits a message. Thus, the function `fn` can read other reactive dependencies,
|
365
|
+
but it will only be re-invoked when the user submits a message.
|
366
|
+
"""
|
367
|
+
|
368
|
+
def create_effect(fn: UserSubmitFunction):
|
369
|
+
fn_params = inspect.signature(fn).parameters
|
370
|
+
|
371
|
+
@reactive.effect
|
372
|
+
@reactive.event(self._user_input)
|
373
|
+
async def handle_user_input():
|
374
|
+
if self._suspend_input_handler:
|
375
|
+
from shiny import req
|
376
|
+
|
377
|
+
req(False)
|
378
|
+
try:
|
379
|
+
if len(fn_params) > 1:
|
380
|
+
raise ValueError(
|
381
|
+
"A on_user_submit function should not take more than 1 argument"
|
382
|
+
)
|
383
|
+
elif len(fn_params) == 1:
|
384
|
+
input = self.user_input(transform=True)
|
385
|
+
# The line immediately below handles the possibility of input
|
386
|
+
# being transformed to None. Technically, input should never be
|
387
|
+
# None at this point (since the handler should be suspended).
|
388
|
+
input = "" if input is None else input
|
389
|
+
afunc = _utils.wrap_async(cast(UserSubmitFunction1, fn))
|
390
|
+
await afunc(input)
|
391
|
+
else:
|
392
|
+
afunc = _utils.wrap_async(cast(UserSubmitFunction0, fn))
|
393
|
+
await afunc()
|
394
|
+
except Exception as e:
|
395
|
+
await self._raise_exception(e)
|
396
|
+
|
397
|
+
self._effects.append(handle_user_input)
|
398
|
+
|
399
|
+
return handle_user_input
|
400
|
+
|
401
|
+
if fn is None:
|
402
|
+
return create_effect
|
403
|
+
else:
|
404
|
+
return create_effect(fn)
|
405
|
+
|
406
|
+
async def _raise_exception(
|
407
|
+
self,
|
408
|
+
e: BaseException,
|
409
|
+
) -> None:
|
410
|
+
if self.on_error == "unhandled":
|
411
|
+
raise e
|
412
|
+
else:
|
413
|
+
await self._remove_loading_message()
|
414
|
+
sanitize = self.on_error == "sanitize"
|
415
|
+
msg = f"Error in Chat('{self.id}'): {str(e)}"
|
416
|
+
raise NotifyException(msg, sanitize=sanitize) from e
|
417
|
+
|
418
|
+
@overload
|
419
|
+
def messages(
|
420
|
+
self,
|
421
|
+
*,
|
422
|
+
format: Literal["anthropic"],
|
423
|
+
token_limits: tuple[int, int] | None = None,
|
424
|
+
transform_user: Literal["all", "last", "none"] = "all",
|
425
|
+
transform_assistant: bool = False,
|
426
|
+
) -> tuple[AnthropicMessage, ...]: ...
|
427
|
+
|
428
|
+
@overload
|
429
|
+
def messages(
|
430
|
+
self,
|
431
|
+
*,
|
432
|
+
format: Literal["google"],
|
433
|
+
token_limits: tuple[int, int] | None = None,
|
434
|
+
transform_user: Literal["all", "last", "none"] = "all",
|
435
|
+
transform_assistant: bool = False,
|
436
|
+
) -> tuple[GoogleMessage, ...]: ...
|
437
|
+
|
438
|
+
@overload
|
439
|
+
def messages(
|
440
|
+
self,
|
441
|
+
*,
|
442
|
+
format: Literal["langchain"],
|
443
|
+
token_limits: tuple[int, int] | None = None,
|
444
|
+
transform_user: Literal["all", "last", "none"] = "all",
|
445
|
+
transform_assistant: bool = False,
|
446
|
+
) -> tuple[LangChainMessage, ...]: ...
|
447
|
+
|
448
|
+
@overload
|
449
|
+
def messages(
|
450
|
+
self,
|
451
|
+
*,
|
452
|
+
format: Literal["openai"],
|
453
|
+
token_limits: tuple[int, int] | None = None,
|
454
|
+
transform_user: Literal["all", "last", "none"] = "all",
|
455
|
+
transform_assistant: bool = False,
|
456
|
+
) -> tuple[OpenAIMessage, ...]: ...
|
457
|
+
|
458
|
+
@overload
|
459
|
+
def messages(
|
460
|
+
self,
|
461
|
+
*,
|
462
|
+
format: Literal["ollama"],
|
463
|
+
token_limits: tuple[int, int] | None = None,
|
464
|
+
transform_user: Literal["all", "last", "none"] = "all",
|
465
|
+
transform_assistant: bool = False,
|
466
|
+
) -> tuple[OllamaMessage, ...]: ...
|
467
|
+
|
468
|
+
@overload
|
469
|
+
def messages(
|
470
|
+
self,
|
471
|
+
*,
|
472
|
+
format: MISSING_TYPE = MISSING,
|
473
|
+
token_limits: tuple[int, int] | None = None,
|
474
|
+
transform_user: Literal["all", "last", "none"] = "all",
|
475
|
+
transform_assistant: bool = False,
|
476
|
+
) -> tuple[ChatMessageDict, ...]: ...
|
477
|
+
|
478
|
+
def messages(
|
479
|
+
self,
|
480
|
+
*,
|
481
|
+
format: MISSING_TYPE | ProviderMessageFormat = MISSING,
|
482
|
+
token_limits: tuple[int, int] | None = None,
|
483
|
+
transform_user: Literal["all", "last", "none"] = "all",
|
484
|
+
transform_assistant: bool = False,
|
485
|
+
) -> tuple[ChatMessageDict | ProviderMessage, ...]:
|
486
|
+
"""
|
487
|
+
Reactively read chat messages
|
488
|
+
|
489
|
+
Obtain chat messages within a reactive context. The default behavior is
|
490
|
+
intended for passing messages along to a model for response generation where
|
491
|
+
you typically want to:
|
492
|
+
|
493
|
+
1. Cap the number of tokens sent in a single request (i.e., `token_limits`).
|
494
|
+
2. Apply user input transformations (i.e., `transform_user`), if any.
|
495
|
+
3. Not apply assistant response transformations (i.e., `transform_assistant`)
|
496
|
+
since these are predominantly for display purposes (i.e., the model shouldn't
|
497
|
+
concern itself with how the responses are displayed).
|
498
|
+
|
499
|
+
Parameters
|
500
|
+
----------
|
501
|
+
format
|
502
|
+
The message format to return. The default value of `MISSING` means
|
503
|
+
chat messages are returned as :class:`ChatMessage` objects (a dictionary
|
504
|
+
with `content` and `role` keys). Other supported formats include:
|
505
|
+
|
506
|
+
* `"anthropic"`: Anthropic message format.
|
507
|
+
* `"google"`: Google message (aka content) format.
|
508
|
+
* `"langchain"`: LangChain message format.
|
509
|
+
* `"openai"`: OpenAI message format.
|
510
|
+
* `"ollama"`: Ollama message format.
|
511
|
+
token_limits
|
512
|
+
Limit the conversation history based on token limits. If specified, only
|
513
|
+
the most recent messages that fit within the token limits are returned. This
|
514
|
+
is useful for avoiding "exceeded token limit" errors when sending messages
|
515
|
+
to the relevant model, while still providing the most recent context available.
|
516
|
+
A specified value must be a tuple of two integers. The first integer is the
|
517
|
+
maximum number of tokens that can be sent to the model in a single request.
|
518
|
+
The second integer is the amount of tokens to reserve for the model's response.
|
519
|
+
Note that token counts based on the `tokenizer` provided to the `Chat`
|
520
|
+
constructor.
|
521
|
+
transform_user
|
522
|
+
Whether to return user input messages with transformation applied. This only
|
523
|
+
matters if a `transform_user_input` was provided to the chat constructor.
|
524
|
+
The default value of `"all"` means all user input messages are transformed.
|
525
|
+
The value of `"last"` means only the last user input message is transformed.
|
526
|
+
The value of `"none"` means no user input messages are transformed.
|
527
|
+
transform_assistant
|
528
|
+
Whether to return assistant messages with transformation applied. This only
|
529
|
+
matters if an `transform_assistant_response` was provided to the chat
|
530
|
+
constructor.
|
531
|
+
|
532
|
+
Note
|
533
|
+
----
|
534
|
+
Messages are listed in the order they were added. As a result, when this method
|
535
|
+
is called in a `.on_user_submit()` callback (as it most often is), the last
|
536
|
+
message will be the most recent one submitted by the user.
|
537
|
+
|
538
|
+
Returns
|
539
|
+
-------
|
540
|
+
tuple[ChatMessage, ...]
|
541
|
+
A tuple of chat messages.
|
542
|
+
"""
|
543
|
+
|
544
|
+
messages = self._messages()
|
545
|
+
|
546
|
+
# Anthropic requires a user message first and no system messages
|
547
|
+
if format == "anthropic":
|
548
|
+
messages = self._trim_anthropic_messages(messages)
|
549
|
+
|
550
|
+
if token_limits is not None:
|
551
|
+
messages = self._trim_messages(messages, token_limits, format)
|
552
|
+
|
553
|
+
res: list[ChatMessageDict | ProviderMessage] = []
|
554
|
+
for i, m in enumerate(messages):
|
555
|
+
transform = False
|
556
|
+
if m.role == "assistant":
|
557
|
+
transform = transform_assistant
|
558
|
+
elif m.role == "user":
|
559
|
+
transform = transform_user == "all" or (
|
560
|
+
transform_user == "last" and i == len(messages) - 1
|
561
|
+
)
|
562
|
+
content_key = getattr(
|
563
|
+
m, "transform_key" if transform else "pre_transform_key"
|
564
|
+
)
|
565
|
+
content = getattr(m, content_key)
|
566
|
+
chat_msg = ChatMessageDict(content=str(content), role=m.role)
|
567
|
+
if not isinstance(format, MISSING_TYPE):
|
568
|
+
chat_msg = as_provider_message(chat_msg, format)
|
569
|
+
res.append(chat_msg)
|
570
|
+
|
571
|
+
return tuple(res)
|
572
|
+
|
573
|
+
async def append_message(
|
574
|
+
self,
|
575
|
+
message: Any,
|
576
|
+
*,
|
577
|
+
icon: HTML | Tag | TagList | None = None,
|
578
|
+
):
|
579
|
+
"""
|
580
|
+
Append a message to the chat.
|
581
|
+
|
582
|
+
Parameters
|
583
|
+
----------
|
584
|
+
message
|
585
|
+
A given message can be one of the following:
|
586
|
+
|
587
|
+
* A string, which is interpreted as markdown and rendered to HTML on the
|
588
|
+
client.
|
589
|
+
* To prevent interpreting as markdown, mark the string as
|
590
|
+
:class:`~shiny.ui.HTML`.
|
591
|
+
* A UI element (specifically, a :class:`~shiny.ui.TagChild`).
|
592
|
+
* This includes :class:`~shiny.ui.TagList`, which take UI elements
|
593
|
+
(including strings) as children. In this case, strings are still
|
594
|
+
interpreted as markdown as long as they're not inside HTML.
|
595
|
+
* A dictionary with `content` and `role` keys. The `content` key can contain
|
596
|
+
content as described above, and the `role` key can be "assistant" or
|
597
|
+
"user".
|
598
|
+
|
599
|
+
**NOTE:** content may include specially formatted **input suggestion** links
|
600
|
+
(see note below).
|
601
|
+
icon
|
602
|
+
An optional icon to display next to the message, currently only used for
|
603
|
+
assistant messages. The icon can be any HTML element (e.g., an
|
604
|
+
:func:`~shiny.ui.img` tag) or a string of HTML.
|
605
|
+
|
606
|
+
Note
|
607
|
+
----
|
608
|
+
:::{.callout-note title="Input suggestions"}
|
609
|
+
Input suggestions are special links that send text to the user input box when
|
610
|
+
clicked (or accessed via keyboard). They can be created in the following ways:
|
611
|
+
|
612
|
+
* `<span class='suggestion'>Suggestion text</span>`: An inline text link that
|
613
|
+
places 'Suggestion text' in the user input box when clicked.
|
614
|
+
* `<img data-suggestion='Suggestion text' src='image.jpg'>`: An image link with
|
615
|
+
the same functionality as above.
|
616
|
+
* `<span data-suggestion='Suggestion text'>Actual text</span>`: An inline text
|
617
|
+
link that places 'Suggestion text' in the user input box when clicked.
|
618
|
+
|
619
|
+
A suggestion can also be submitted automatically by doing one of the following:
|
620
|
+
|
621
|
+
* Adding a `submit` CSS class or a `data-suggestion-submit="true"` attribute to
|
622
|
+
the suggestion element.
|
623
|
+
* Holding the `Ctrl/Cmd` key while clicking the suggestion link.
|
624
|
+
|
625
|
+
Note that a user may also opt-out of submitting a suggestion by holding the
|
626
|
+
`Alt/Option` key while clicking the suggestion link.
|
627
|
+
:::
|
628
|
+
|
629
|
+
:::{.callout-note title="Streamed messages"}
|
630
|
+
Use `.append_message_stream()` instead of this method when `stream=True` (or
|
631
|
+
similar) is specified in model's completion method.
|
632
|
+
:::
|
633
|
+
"""
|
634
|
+
# If we're in a stream, queue the message
|
635
|
+
if self._current_stream_id:
|
636
|
+
self._pending_messages.append((message, False, "append", None))
|
637
|
+
return
|
638
|
+
|
639
|
+
msg = normalize_message(message)
|
640
|
+
msg = await self._transform_message(msg)
|
641
|
+
if msg is None:
|
642
|
+
return
|
643
|
+
self._store_message(msg)
|
644
|
+
await self._send_append_message(
|
645
|
+
message=msg,
|
646
|
+
chunk=False,
|
647
|
+
icon=icon,
|
648
|
+
)
|
649
|
+
|
650
|
+
@asynccontextmanager
|
651
|
+
async def message_stream_context(self):
|
652
|
+
"""
|
653
|
+
Message stream context manager.
|
654
|
+
|
655
|
+
A context manager for appending streaming messages into the chat. This context
|
656
|
+
manager can:
|
657
|
+
|
658
|
+
1. Be used in isolation to append a new streaming message to the chat.
|
659
|
+
* Compared to `.append_message_stream()` this method is more flexible but
|
660
|
+
isn't non-blocking by default (i.e., it doesn't launch an extended task).
|
661
|
+
2. Be nested within itself
|
662
|
+
* Nesting is primarily useful for making checkpoints to `.clear()` back
|
663
|
+
to (see the example below).
|
664
|
+
3. Be used from within a `.append_message_stream()`
|
665
|
+
* Useful for inserting additional content from another context into the
|
666
|
+
stream (e.g., see the note about tool calls below).
|
667
|
+
|
668
|
+
Yields
|
669
|
+
------
|
670
|
+
:
|
671
|
+
A `MessageStream` class instance, which has a method for `.append()`ing
|
672
|
+
message content chunks to as well as way to `.clear()` the stream back to
|
673
|
+
it's initial state. Note that `.append()` supports the same message content
|
674
|
+
types as `.append_message()`.
|
675
|
+
|
676
|
+
Example
|
677
|
+
-------
|
678
|
+
```python
|
679
|
+
import asyncio
|
680
|
+
|
681
|
+
from shiny import reactive
|
682
|
+
from shiny.express import ui
|
683
|
+
|
684
|
+
chat = ui.Chat(id="my_chat")
|
685
|
+
chat.ui()
|
686
|
+
|
687
|
+
|
688
|
+
@reactive.effect
|
689
|
+
async def _():
|
690
|
+
async with chat.message_stream_context() as msg:
|
691
|
+
await msg.append("Starting stream...\n\nProgress:")
|
692
|
+
async with chat.message_stream_context() as progress:
|
693
|
+
for x in [0, 50, 100]:
|
694
|
+
await progress.append(f" {x}%")
|
695
|
+
await asyncio.sleep(1)
|
696
|
+
await progress.clear()
|
697
|
+
await msg.clear()
|
698
|
+
await msg.append("Completed stream")
|
699
|
+
```
|
700
|
+
|
701
|
+
Note
|
702
|
+
----
|
703
|
+
A useful pattern for displaying tool calls in a chatbot is for the tool to
|
704
|
+
display using `.message_stream_context()` while the the response generation is
|
705
|
+
happening through `.append_message_stream()`. This allows the tool to display
|
706
|
+
things like progress updates (or other "ephemeral" content) and optionally
|
707
|
+
`.clear()` the stream back to it's initial state when ready to display the
|
708
|
+
"final" content.
|
709
|
+
"""
|
710
|
+
# Checkpoint the current stream state so operation="replace" can return to it
|
711
|
+
old_checkpoint = self._message_stream_checkpoint
|
712
|
+
self._message_stream_checkpoint = self._current_stream_message
|
713
|
+
|
714
|
+
# No stream currently exists, start one
|
715
|
+
stream_id = self._current_stream_id
|
716
|
+
is_root_stream = stream_id is None
|
717
|
+
if is_root_stream:
|
718
|
+
stream_id = _utils.private_random_id()
|
719
|
+
await self._append_message_chunk(
|
720
|
+
"", chunk="start", stream_id=stream_id
|
721
|
+
)
|
722
|
+
|
723
|
+
try:
|
724
|
+
yield MessageStream(self, stream_id)
|
725
|
+
finally:
|
726
|
+
# Restore the checkpoint
|
727
|
+
self._message_stream_checkpoint = old_checkpoint
|
728
|
+
|
729
|
+
# If this was the root stream, end it
|
730
|
+
if is_root_stream:
|
731
|
+
await self._append_message_chunk(
|
732
|
+
"",
|
733
|
+
chunk="end",
|
734
|
+
stream_id=stream_id,
|
735
|
+
)
|
736
|
+
|
737
|
+
async def _append_message_chunk(
|
738
|
+
self,
|
739
|
+
message: Any,
|
740
|
+
*,
|
741
|
+
chunk: Literal[True, "start", "end"] = True,
|
742
|
+
stream_id: str,
|
743
|
+
operation: Literal["append", "replace"] = "append",
|
744
|
+
icon: HTML | Tag | TagList | None = None,
|
745
|
+
) -> None:
|
746
|
+
# If currently we're in a *different* stream, queue the message chunk
|
747
|
+
if self._current_stream_id and self._current_stream_id != stream_id:
|
748
|
+
self._pending_messages.append(
|
749
|
+
(message, chunk, operation, stream_id)
|
750
|
+
)
|
751
|
+
return
|
752
|
+
|
753
|
+
self._current_stream_id = stream_id
|
754
|
+
|
755
|
+
# Normalize various message types into a ChatMessage()
|
756
|
+
msg = normalize_message_chunk(message)
|
757
|
+
|
758
|
+
if operation == "replace":
|
759
|
+
self._current_stream_message = (
|
760
|
+
self._message_stream_checkpoint + msg.content
|
761
|
+
)
|
762
|
+
msg.content = self._current_stream_message
|
763
|
+
else:
|
764
|
+
self._current_stream_message += msg.content
|
765
|
+
|
766
|
+
try:
|
767
|
+
if self._needs_transform(msg):
|
768
|
+
# Transforming may change the meaning of msg.content to be a *replace*
|
769
|
+
# not *append*. So, update msg.content and the operation accordingly.
|
770
|
+
chunk_content = msg.content
|
771
|
+
msg.content = self._current_stream_message
|
772
|
+
operation = "replace"
|
773
|
+
msg = await self._transform_message(
|
774
|
+
msg, chunk=chunk, chunk_content=chunk_content
|
775
|
+
)
|
776
|
+
# Act like nothing happened if transformed to None
|
777
|
+
if msg is None:
|
778
|
+
return
|
779
|
+
if chunk == "end":
|
780
|
+
self._store_message(msg)
|
781
|
+
elif chunk == "end":
|
782
|
+
# When `operation="append"`, msg.content is just a chunk, but we must
|
783
|
+
# store the full message
|
784
|
+
self._store_message(
|
785
|
+
ChatMessage(
|
786
|
+
content=self._current_stream_message, role=msg.role
|
787
|
+
)
|
788
|
+
)
|
789
|
+
|
790
|
+
# Send the message to the client
|
791
|
+
await self._send_append_message(
|
792
|
+
message=msg,
|
793
|
+
chunk=chunk,
|
794
|
+
operation=operation,
|
795
|
+
icon=icon,
|
796
|
+
)
|
797
|
+
finally:
|
798
|
+
if chunk == "end":
|
799
|
+
self._current_stream_id = None
|
800
|
+
self._current_stream_message = ""
|
801
|
+
self._message_stream_checkpoint = ""
|
802
|
+
|
803
|
+
async def append_message_stream(
|
804
|
+
self,
|
805
|
+
message: Iterable[Any] | AsyncIterable[Any],
|
806
|
+
*,
|
807
|
+
icon: HTML | Tag | None = None,
|
808
|
+
):
|
809
|
+
"""
|
810
|
+
Append a message as a stream of message chunks.
|
811
|
+
|
812
|
+
Parameters
|
813
|
+
----------
|
814
|
+
message
|
815
|
+
An (async) iterable of message chunks. Each chunk can be one of the
|
816
|
+
following:
|
817
|
+
|
818
|
+
* A string, which is interpreted as markdown and rendered to HTML on the
|
819
|
+
client.
|
820
|
+
* To prevent interpreting as markdown, mark the string as
|
821
|
+
:class:`~shiny.ui.HTML`.
|
822
|
+
* A UI element (specifically, a :class:`~shiny.ui.TagChild`).
|
823
|
+
* This includes :class:`~shiny.ui.TagList`, which take UI elements
|
824
|
+
(including strings) as children. In this case, strings are still
|
825
|
+
interpreted as markdown as long as they're not inside HTML.
|
826
|
+
* A dictionary with `content` and `role` keys. The `content` key can contain
|
827
|
+
content as described above, and the `role` key can be "assistant" or
|
828
|
+
"user".
|
829
|
+
|
830
|
+
**NOTE:** content may include specially formatted **input suggestion** links
|
831
|
+
(see note below).
|
832
|
+
icon
|
833
|
+
An optional icon to display next to the message, currently only used for
|
834
|
+
assistant messages. The icon can be any HTML element (e.g., an
|
835
|
+
:func:`~shiny.ui.img` tag) or a string of HTML.
|
836
|
+
|
837
|
+
Note
|
838
|
+
----
|
839
|
+
```{.callout-note title="Input suggestions"}
|
840
|
+
Input suggestions are special links that send text to the user input box when
|
841
|
+
clicked (or accessed via keyboard). They can be created in the following ways:
|
842
|
+
|
843
|
+
* `<span class='suggestion'>Suggestion text</span>`: An inline text link that
|
844
|
+
places 'Suggestion text' in the user input box when clicked.
|
845
|
+
* `<img data-suggestion='Suggestion text' src='image.jpg'>`: An image link with
|
846
|
+
the same functionality as above.
|
847
|
+
* `<span data-suggestion='Suggestion text'>Actual text</span>`: An inline text
|
848
|
+
link that places 'Suggestion text' in the user input box when clicked.
|
849
|
+
|
850
|
+
A suggestion can also be submitted automatically by doing one of the following:
|
851
|
+
|
852
|
+
* Adding a `submit` CSS class or a `data-suggestion-submit="true"` attribute to
|
853
|
+
the suggestion element.
|
854
|
+
* Holding the `Ctrl/Cmd` key while clicking the suggestion link.
|
855
|
+
|
856
|
+
Note that a user may also opt-out of submitting a suggestion by holding the
|
857
|
+
`Alt/Option` key while clicking the suggestion link.
|
858
|
+
```
|
859
|
+
|
860
|
+
```{.callout-note title="Streamed messages"}
|
861
|
+
Use this method (over `.append_message()`) when `stream=True` (or similar) is
|
862
|
+
specified in model's completion method.
|
863
|
+
```
|
864
|
+
|
865
|
+
Returns
|
866
|
+
-------
|
867
|
+
:
|
868
|
+
An extended task that represents the streaming task. The `.result()` method
|
869
|
+
of the task can be called in a reactive context to get the final state of the
|
870
|
+
stream.
|
871
|
+
"""
|
872
|
+
|
873
|
+
message = _utils.wrap_async_iterable(message)
|
874
|
+
|
875
|
+
# Run the stream in the background to get non-blocking behavior
|
876
|
+
@reactive.extended_task
|
877
|
+
async def _stream_task():
|
878
|
+
return await self._append_message_stream(message, icon=icon)
|
879
|
+
|
880
|
+
_stream_task()
|
881
|
+
|
882
|
+
self._latest_stream.set(_stream_task)
|
883
|
+
|
884
|
+
# Since the task runs in the background (outside/beyond the current context,
|
885
|
+
# if any), we need to manually raise any exceptions that occur
|
886
|
+
@reactive.effect
|
887
|
+
async def _handle_error():
|
888
|
+
e = _stream_task.error()
|
889
|
+
if e:
|
890
|
+
await self._raise_exception(e)
|
891
|
+
_handle_error.destroy() # type: ignore
|
892
|
+
|
893
|
+
return _stream_task
|
894
|
+
|
895
|
+
@property
|
896
|
+
def latest_message_stream(self) -> reactive.ExtendedTask[[], str]:
|
897
|
+
"""
|
898
|
+
React to changes in the latest message stream.
|
899
|
+
|
900
|
+
Reactively reads for the :class:`~shiny.reactive.ExtendedTask` behind an
|
901
|
+
`.append_message_stream()`.
|
902
|
+
|
903
|
+
From the return value (i.e., the extended task), you can then:
|
904
|
+
|
905
|
+
1. Reactively read for the final `.result()`.
|
906
|
+
2. `.cancel()` the stream.
|
907
|
+
3. Check the `.status()` of the stream.
|
908
|
+
|
909
|
+
Returns
|
910
|
+
-------
|
911
|
+
:
|
912
|
+
An extended task that represents the streaming task. The `.result()` method
|
913
|
+
of the task can be called in a reactive context to get the final state of the
|
914
|
+
stream.
|
915
|
+
|
916
|
+
Note
|
917
|
+
----
|
918
|
+
If no stream has yet been started when this method is called, then it returns an
|
919
|
+
extended task with `.status()` of `"initial"` and that it status doesn't change
|
920
|
+
state until a message is streamed.
|
921
|
+
"""
|
922
|
+
return self._latest_stream()
|
923
|
+
|
924
|
+
async def _append_message_stream(
|
925
|
+
self,
|
926
|
+
message: AsyncIterable[Any],
|
927
|
+
icon: HTML | Tag | None = None,
|
928
|
+
):
|
929
|
+
id = _utils.private_random_id()
|
930
|
+
|
931
|
+
empty = ChatMessageDict(content="", role="assistant")
|
932
|
+
await self._append_message_chunk(
|
933
|
+
empty, chunk="start", stream_id=id, icon=icon
|
934
|
+
)
|
935
|
+
|
936
|
+
try:
|
937
|
+
async for msg in message:
|
938
|
+
await self._append_message_chunk(msg, chunk=True, stream_id=id)
|
939
|
+
return self._current_stream_message
|
940
|
+
finally:
|
941
|
+
await self._append_message_chunk(empty, chunk="end", stream_id=id)
|
942
|
+
await self._flush_pending_messages()
|
943
|
+
|
944
|
+
async def _flush_pending_messages(self):
|
945
|
+
pending = self._pending_messages
|
946
|
+
self._pending_messages = []
|
947
|
+
for msg, chunk, operation, stream_id in pending:
|
948
|
+
if chunk is False:
|
949
|
+
await self.append_message(msg)
|
950
|
+
else:
|
951
|
+
await self._append_message_chunk(
|
952
|
+
msg,
|
953
|
+
chunk=chunk,
|
954
|
+
operation=operation,
|
955
|
+
stream_id=cast(str, stream_id),
|
956
|
+
)
|
957
|
+
|
958
|
+
# Send a message to the UI
|
959
|
+
async def _send_append_message(
|
960
|
+
self,
|
961
|
+
message: TransformedMessage | ChatMessage,
|
962
|
+
chunk: ChunkOption = False,
|
963
|
+
operation: Literal["append", "replace"] = "append",
|
964
|
+
icon: HTML | Tag | TagList | None = None,
|
965
|
+
):
|
966
|
+
if not isinstance(message, TransformedMessage):
|
967
|
+
message = TransformedMessage.from_chat_message(message)
|
968
|
+
|
969
|
+
if message.role == "system":
|
970
|
+
# System messages are not displayed in the UI
|
971
|
+
return
|
972
|
+
|
973
|
+
if chunk:
|
974
|
+
msg_type = "shiny-chat-append-message-chunk"
|
975
|
+
else:
|
976
|
+
msg_type = "shiny-chat-append-message"
|
977
|
+
|
978
|
+
chunk_type = None
|
979
|
+
if chunk == "start":
|
980
|
+
chunk_type = "message_start"
|
981
|
+
elif chunk == "end":
|
982
|
+
chunk_type = "message_end"
|
983
|
+
|
984
|
+
content = message.content_client
|
985
|
+
content_type = "html" if isinstance(content, HTML) else "markdown"
|
986
|
+
|
987
|
+
# TODO: pass along dependencies for both content and icon (if any)
|
988
|
+
msg = ClientMessage(
|
989
|
+
content=str(content),
|
990
|
+
role=message.role,
|
991
|
+
content_type=content_type,
|
992
|
+
chunk_type=chunk_type,
|
993
|
+
operation=operation,
|
994
|
+
)
|
995
|
+
|
996
|
+
if icon is not None:
|
997
|
+
msg["icon"] = str(icon)
|
998
|
+
|
999
|
+
deps = message.html_deps
|
1000
|
+
if deps:
|
1001
|
+
msg["html_deps"] = deps
|
1002
|
+
|
1003
|
+
# print(msg)
|
1004
|
+
|
1005
|
+
await self._send_custom_message(msg_type, msg)
|
1006
|
+
# TODO: Joe said it's a good idea to yield here, but I'm not sure why?
|
1007
|
+
# await asyncio.sleep(0)
|
1008
|
+
|
1009
|
+
@overload
|
1010
|
+
def transform_user_input(
|
1011
|
+
self, fn: TransformUserInput | TransformUserInputAsync
|
1012
|
+
) -> None: ...
|
1013
|
+
|
1014
|
+
@overload
|
1015
|
+
def transform_user_input(
|
1016
|
+
self,
|
1017
|
+
) -> Callable[[TransformUserInput | TransformUserInputAsync], None]: ...
|
1018
|
+
|
1019
|
+
def transform_user_input(
|
1020
|
+
self, fn: TransformUserInput | TransformUserInputAsync | None = None
|
1021
|
+
) -> None | Callable[[TransformUserInput | TransformUserInputAsync], None]:
|
1022
|
+
"""
|
1023
|
+
Transform user input.
|
1024
|
+
|
1025
|
+
Use this method as a decorator on a function (`fn`) that transforms user input
|
1026
|
+
before storing it in the chat messages returned by `.messages()`. This is
|
1027
|
+
useful for implementing RAG workflows, like taking a URL and scraping it for
|
1028
|
+
text before sending it to the model.
|
1029
|
+
|
1030
|
+
Parameters
|
1031
|
+
----------
|
1032
|
+
fn
|
1033
|
+
A function to transform user input before storing it in the chat
|
1034
|
+
`.messages()`. If `fn` returns `None`, the user input is effectively
|
1035
|
+
ignored, and `.on_user_submit()` callbacks are suspended until more input is
|
1036
|
+
submitted. This behavior is often useful to catch and handle errors that
|
1037
|
+
occur during transformation. In this case, the transform function should
|
1038
|
+
append an error message to the chat (via `.append_message()`) to inform the
|
1039
|
+
user of the error.
|
1040
|
+
"""
|
1041
|
+
|
1042
|
+
def _set_transform(fn: TransformUserInput | TransformUserInputAsync):
|
1043
|
+
self._transform_user = _utils.wrap_async(fn)
|
1044
|
+
|
1045
|
+
if fn is None:
|
1046
|
+
return _set_transform
|
1047
|
+
else:
|
1048
|
+
return _set_transform(fn)
|
1049
|
+
|
1050
|
+
@overload
|
1051
|
+
def transform_assistant_response(
|
1052
|
+
self, fn: TransformAssistantResponseFunction
|
1053
|
+
) -> None: ...
|
1054
|
+
|
1055
|
+
@overload
|
1056
|
+
def transform_assistant_response(
|
1057
|
+
self,
|
1058
|
+
) -> Callable[[TransformAssistantResponseFunction], None]: ...
|
1059
|
+
|
1060
|
+
def transform_assistant_response(
|
1061
|
+
self,
|
1062
|
+
fn: TransformAssistantResponseFunction | None = None,
|
1063
|
+
) -> None | Callable[[TransformAssistantResponseFunction], None]:
|
1064
|
+
"""
|
1065
|
+
Transform assistant responses.
|
1066
|
+
|
1067
|
+
Use this method as a decorator on a function (`fn`) that transforms assistant
|
1068
|
+
responses before displaying them in the chat. This is useful for post-processing
|
1069
|
+
model responses before displaying them to the user.
|
1070
|
+
|
1071
|
+
Parameters
|
1072
|
+
----------
|
1073
|
+
fn
|
1074
|
+
A function that takes a string and returns either a string,
|
1075
|
+
:class:`shiny.ui.HTML`, or `None`. If `fn` returns a string, it gets
|
1076
|
+
interpreted and parsed as a markdown on the client (and the resulting HTML
|
1077
|
+
is then sanitized). If `fn` returns :class:`shiny.ui.HTML`, it will be
|
1078
|
+
displayed as-is. If `fn` returns `None`, the response is effectively ignored.
|
1079
|
+
|
1080
|
+
Note
|
1081
|
+
----
|
1082
|
+
When doing an `.append_message_stream()`, `fn` gets called on every chunk of the
|
1083
|
+
response (thus, it should be performant), and can optionally access more
|
1084
|
+
information (i.e., arguments) about the stream. The 1st argument (required)
|
1085
|
+
contains the accumulated content, the 2nd argument (optional) contains the
|
1086
|
+
current chunk, and the 3rd argument (optional) is a boolean indicating whether
|
1087
|
+
this chunk is the last one in the stream.
|
1088
|
+
"""
|
1089
|
+
|
1090
|
+
def _set_transform(
|
1091
|
+
fn: TransformAssistantResponseFunction,
|
1092
|
+
):
|
1093
|
+
nparams = len(inspect.signature(fn).parameters)
|
1094
|
+
if nparams == 1:
|
1095
|
+
fn = cast(
|
1096
|
+
Union[
|
1097
|
+
TransformAssistantResponse,
|
1098
|
+
TransformAssistantResponseAsync,
|
1099
|
+
],
|
1100
|
+
fn,
|
1101
|
+
)
|
1102
|
+
fn = _utils.wrap_async(fn)
|
1103
|
+
|
1104
|
+
async def _transform_wrapper(
|
1105
|
+
content: str, chunk: str, done: bool
|
1106
|
+
):
|
1107
|
+
return await fn(content)
|
1108
|
+
|
1109
|
+
self._transform_assistant = _transform_wrapper
|
1110
|
+
|
1111
|
+
elif nparams == 3:
|
1112
|
+
fn = cast(
|
1113
|
+
Union[
|
1114
|
+
TransformAssistantResponseChunk,
|
1115
|
+
TransformAssistantResponseChunkAsync,
|
1116
|
+
],
|
1117
|
+
fn,
|
1118
|
+
)
|
1119
|
+
self._transform_assistant = _utils.wrap_async(fn)
|
1120
|
+
else:
|
1121
|
+
raise Exception(
|
1122
|
+
"A @transform_assistant_response function must take 1 or 3 arguments"
|
1123
|
+
)
|
1124
|
+
|
1125
|
+
if fn is None:
|
1126
|
+
return _set_transform
|
1127
|
+
else:
|
1128
|
+
return _set_transform(fn)
|
1129
|
+
|
1130
|
+
async def _transform_message(
|
1131
|
+
self,
|
1132
|
+
message: ChatMessage,
|
1133
|
+
chunk: ChunkOption = False,
|
1134
|
+
chunk_content: str = "",
|
1135
|
+
) -> TransformedMessage | None:
|
1136
|
+
res = TransformedMessage.from_chat_message(message)
|
1137
|
+
|
1138
|
+
if message.role == "user" and self._transform_user is not None:
|
1139
|
+
content = await self._transform_user(message.content)
|
1140
|
+
elif (
|
1141
|
+
message.role == "assistant"
|
1142
|
+
and self._transform_assistant is not None
|
1143
|
+
):
|
1144
|
+
content = await self._transform_assistant(
|
1145
|
+
message.content,
|
1146
|
+
chunk_content,
|
1147
|
+
chunk == "end" or chunk is False,
|
1148
|
+
)
|
1149
|
+
else:
|
1150
|
+
return res
|
1151
|
+
|
1152
|
+
if content is None:
|
1153
|
+
return None
|
1154
|
+
|
1155
|
+
setattr(res, res.transform_key, content)
|
1156
|
+
return res
|
1157
|
+
|
1158
|
+
def _needs_transform(self, message: ChatMessage) -> bool:
|
1159
|
+
if message.role == "user" and self._transform_user is not None:
|
1160
|
+
return True
|
1161
|
+
elif (
|
1162
|
+
message.role == "assistant"
|
1163
|
+
and self._transform_assistant is not None
|
1164
|
+
):
|
1165
|
+
return True
|
1166
|
+
return False
|
1167
|
+
|
1168
|
+
# Just before storing, handle chunk msg type and calculate tokens
|
1169
|
+
def _store_message(
|
1170
|
+
self,
|
1171
|
+
message: TransformedMessage | ChatMessage,
|
1172
|
+
index: int | None = None,
|
1173
|
+
) -> None:
|
1174
|
+
if not isinstance(message, TransformedMessage):
|
1175
|
+
message = TransformedMessage.from_chat_message(message)
|
1176
|
+
|
1177
|
+
with reactive.isolate():
|
1178
|
+
messages = self._messages()
|
1179
|
+
|
1180
|
+
if index is None:
|
1181
|
+
index = len(messages)
|
1182
|
+
|
1183
|
+
messages = list(messages)
|
1184
|
+
messages.insert(index, message)
|
1185
|
+
|
1186
|
+
self._messages.set(tuple(messages))
|
1187
|
+
if message.role == "user":
|
1188
|
+
self._latest_user_input.set(message)
|
1189
|
+
|
1190
|
+
return None
|
1191
|
+
|
1192
|
+
def _trim_messages(
|
1193
|
+
self,
|
1194
|
+
messages: tuple[TransformedMessage, ...],
|
1195
|
+
token_limits: tuple[int, int],
|
1196
|
+
format: MISSING_TYPE | ProviderMessageFormat,
|
1197
|
+
) -> tuple[TransformedMessage, ...]:
|
1198
|
+
n_total, n_reserve = token_limits
|
1199
|
+
if n_total <= n_reserve:
|
1200
|
+
raise ValueError(
|
1201
|
+
f"Invalid token limits: {token_limits}. The 1st value must be greater "
|
1202
|
+
"than the 2nd value."
|
1203
|
+
)
|
1204
|
+
|
1205
|
+
# Since don't trim system messages, 1st obtain their total token count
|
1206
|
+
# (so we can determine how many non-system messages can fit)
|
1207
|
+
n_system_tokens: int = 0
|
1208
|
+
n_system_messages: int = 0
|
1209
|
+
n_other_messages: int = 0
|
1210
|
+
token_counts: list[int] = []
|
1211
|
+
for m in messages:
|
1212
|
+
count = self._get_token_count(m.content_server)
|
1213
|
+
token_counts.append(count)
|
1214
|
+
if m.role == "system":
|
1215
|
+
n_system_tokens += count
|
1216
|
+
n_system_messages += 1
|
1217
|
+
else:
|
1218
|
+
n_other_messages += 1
|
1219
|
+
|
1220
|
+
remaining_non_system_tokens = n_total - n_reserve - n_system_tokens
|
1221
|
+
|
1222
|
+
if remaining_non_system_tokens <= 0:
|
1223
|
+
raise ValueError(
|
1224
|
+
f"System messages exceed `.messages(token_limits={token_limits})`. "
|
1225
|
+
"Consider increasing the 1st value of `token_limit` or setting it to "
|
1226
|
+
"`token_limit=None` to disable token limits."
|
1227
|
+
)
|
1228
|
+
|
1229
|
+
# Now, iterate through the messages in reverse order and appending
|
1230
|
+
# until we run out of tokens
|
1231
|
+
messages2: list[TransformedMessage] = []
|
1232
|
+
n_other_messages2: int = 0
|
1233
|
+
token_counts.reverse()
|
1234
|
+
for i, m in enumerate(reversed(messages)):
|
1235
|
+
if m.role == "system":
|
1236
|
+
messages2.append(m)
|
1237
|
+
continue
|
1238
|
+
remaining_non_system_tokens -= token_counts[i]
|
1239
|
+
if remaining_non_system_tokens >= 0:
|
1240
|
+
messages2.append(m)
|
1241
|
+
n_other_messages2 += 1
|
1242
|
+
|
1243
|
+
messages2.reverse()
|
1244
|
+
|
1245
|
+
if len(messages2) == n_system_messages and n_other_messages2 > 0:
|
1246
|
+
raise ValueError(
|
1247
|
+
f"Only system messages fit within `.messages(token_limits={token_limits})`. "
|
1248
|
+
"Consider increasing the 1st value of `token_limit` or setting it to "
|
1249
|
+
"`token_limit=None` to disable token limits."
|
1250
|
+
)
|
1251
|
+
|
1252
|
+
return tuple(messages2)
|
1253
|
+
|
1254
|
+
def _trim_anthropic_messages(
|
1255
|
+
self,
|
1256
|
+
messages: tuple[TransformedMessage, ...],
|
1257
|
+
) -> tuple[TransformedMessage, ...]:
|
1258
|
+
if any(m.role == "system" for m in messages):
|
1259
|
+
raise ValueError(
|
1260
|
+
"Anthropic requires a system prompt to be specified in it's `.create()` method "
|
1261
|
+
"(not in the chat messages with `role: system`)."
|
1262
|
+
)
|
1263
|
+
for i, m in enumerate(messages):
|
1264
|
+
if m.role == "user":
|
1265
|
+
return messages[i:]
|
1266
|
+
|
1267
|
+
return ()
|
1268
|
+
|
1269
|
+
def _get_token_count(
|
1270
|
+
self,
|
1271
|
+
content: str,
|
1272
|
+
) -> int:
|
1273
|
+
if self._tokenizer is None:
|
1274
|
+
self._tokenizer = get_default_tokenizer()
|
1275
|
+
|
1276
|
+
encoded = self._tokenizer.encode(content)
|
1277
|
+
if isinstance(encoded, TokenizersEncoding):
|
1278
|
+
return len(encoded.ids)
|
1279
|
+
else:
|
1280
|
+
return len(encoded)
|
1281
|
+
|
1282
|
+
def user_input(self, transform: bool = False) -> str | None:
|
1283
|
+
"""
|
1284
|
+
Reactively read the user's message.
|
1285
|
+
|
1286
|
+
Parameters
|
1287
|
+
----------
|
1288
|
+
transform
|
1289
|
+
Whether to apply the user input transformation function (if one was
|
1290
|
+
provided).
|
1291
|
+
|
1292
|
+
Returns
|
1293
|
+
-------
|
1294
|
+
str | None
|
1295
|
+
The user input message (before any transformation).
|
1296
|
+
|
1297
|
+
Note
|
1298
|
+
----
|
1299
|
+
Most users shouldn't need to use this method directly since the last item in
|
1300
|
+
`.messages()` contains the most recent user input. It can be useful for:
|
1301
|
+
|
1302
|
+
1. Taking a reactive dependency on the user's input outside of a `.on_user_submit()` callback.
|
1303
|
+
2. Maintaining message state separately from `.messages()`.
|
1304
|
+
|
1305
|
+
"""
|
1306
|
+
msg = self._latest_user_input()
|
1307
|
+
if msg is None:
|
1308
|
+
return None
|
1309
|
+
key = "content_server" if transform else "content_client"
|
1310
|
+
val = getattr(msg, key)
|
1311
|
+
return str(val)
|
1312
|
+
|
1313
|
+
def _user_input(self) -> str:
|
1314
|
+
id = self.user_input_id
|
1315
|
+
return cast(str, self._session.input[id]())
|
1316
|
+
|
1317
|
+
def update_user_input(
|
1318
|
+
self,
|
1319
|
+
*,
|
1320
|
+
value: str | None = None,
|
1321
|
+
placeholder: str | None = None,
|
1322
|
+
submit: bool = False,
|
1323
|
+
focus: bool = False,
|
1324
|
+
):
|
1325
|
+
"""
|
1326
|
+
Update the user input.
|
1327
|
+
|
1328
|
+
Parameters
|
1329
|
+
----------
|
1330
|
+
value
|
1331
|
+
The value to set the user input to.
|
1332
|
+
placeholder
|
1333
|
+
The placeholder text for the user input.
|
1334
|
+
submit
|
1335
|
+
Whether to automatically submit the text for the user. Requires `value`.
|
1336
|
+
focus
|
1337
|
+
Whether to move focus to the input element. Requires `value`.
|
1338
|
+
"""
|
1339
|
+
|
1340
|
+
if value is None and (submit or focus):
|
1341
|
+
raise ValueError(
|
1342
|
+
"An input `value` must be provided when `submit` or `focus` are `True`."
|
1343
|
+
)
|
1344
|
+
|
1345
|
+
obj = _utils.drop_none(
|
1346
|
+
{
|
1347
|
+
"value": value,
|
1348
|
+
"placeholder": placeholder,
|
1349
|
+
"submit": submit,
|
1350
|
+
"focus": focus,
|
1351
|
+
}
|
1352
|
+
)
|
1353
|
+
|
1354
|
+
msg = {
|
1355
|
+
"id": self.id,
|
1356
|
+
"handler": "shiny-chat-update-user-input",
|
1357
|
+
"obj": obj,
|
1358
|
+
}
|
1359
|
+
|
1360
|
+
self._session._send_message_sync({"custom": {"shinyChatMessage": msg}})
|
1361
|
+
|
1362
|
+
def set_user_message(self, value: str):
|
1363
|
+
"""
|
1364
|
+
Deprecated. Use `update_user_input(value=value)` instead.
|
1365
|
+
"""
|
1366
|
+
|
1367
|
+
warn_deprecated(
|
1368
|
+
"set_user_message() is deprecated. Use update_user_input(value=value) instead."
|
1369
|
+
)
|
1370
|
+
|
1371
|
+
self.update_user_input(value=value)
|
1372
|
+
|
1373
|
+
async def clear_messages(self):
|
1374
|
+
"""
|
1375
|
+
Clear all chat messages.
|
1376
|
+
"""
|
1377
|
+
self._messages.set(())
|
1378
|
+
await self._send_custom_message("shiny-chat-clear-messages", None)
|
1379
|
+
|
1380
|
+
def destroy(self):
|
1381
|
+
"""
|
1382
|
+
Destroy the chat instance.
|
1383
|
+
"""
|
1384
|
+
self._destroy_effects()
|
1385
|
+
self._destroy_bookmarking()
|
1386
|
+
|
1387
|
+
def _destroy_effects(self):
|
1388
|
+
for x in self._effects:
|
1389
|
+
x.destroy()
|
1390
|
+
self._effects.clear()
|
1391
|
+
|
1392
|
+
def _destroy_bookmarking(self):
|
1393
|
+
if not self._cancel_bookmarking_callbacks:
|
1394
|
+
return
|
1395
|
+
|
1396
|
+
self._cancel_bookmarking_callbacks()
|
1397
|
+
self._cancel_bookmarking_callbacks = None
|
1398
|
+
|
1399
|
+
async def _remove_loading_message(self):
|
1400
|
+
await self._send_custom_message(
|
1401
|
+
"shiny-chat-remove-loading-message", None
|
1402
|
+
)
|
1403
|
+
|
1404
|
+
async def _send_custom_message(
|
1405
|
+
self, handler: str, obj: ClientMessage | None
|
1406
|
+
):
|
1407
|
+
await self._session.send_custom_message(
|
1408
|
+
"shinyChatMessage",
|
1409
|
+
{
|
1410
|
+
"id": self.id,
|
1411
|
+
"handler": handler,
|
1412
|
+
"obj": obj,
|
1413
|
+
},
|
1414
|
+
)
|
1415
|
+
|
1416
|
+
def enable_bookmarking(
|
1417
|
+
self,
|
1418
|
+
client: ClientWithState | chatlas.Chat[Any, Any],
|
1419
|
+
/,
|
1420
|
+
*,
|
1421
|
+
bookmark_on: Optional[Literal["response"]] = "response",
|
1422
|
+
) -> CancelCallback:
|
1423
|
+
"""
|
1424
|
+
Enable bookmarking for the chat instance.
|
1425
|
+
|
1426
|
+
This method registers `on_bookmark` and `on_restore` hooks on `session.bookmark`
|
1427
|
+
(:class:`shiny.bookmark.Bookmark`) to save/restore chat state on both the `Chat`
|
1428
|
+
and `client=` instances. In order for this method to actually work correctly, a
|
1429
|
+
`bookmark_store=` must be specified in `shiny.App()`.
|
1430
|
+
|
1431
|
+
Parameters
|
1432
|
+
----------
|
1433
|
+
client
|
1434
|
+
The chat client instance to use for bookmarking. This can be a Chat model
|
1435
|
+
provider from [chatlas](https://posit-dev.github.io/chatlas/), or more
|
1436
|
+
generally, an instance following the `ClientWithState` protocol.
|
1437
|
+
bookmark_on
|
1438
|
+
The event to trigger the bookmarking on. Supported values include:
|
1439
|
+
|
1440
|
+
- `"response"` (the default): a bookmark is triggered when the assistant is done responding.
|
1441
|
+
- `None`: no bookmark is triggered
|
1442
|
+
|
1443
|
+
When this method triggers a bookmark, it also updates the URL query string to reflect the bookmarked state.
|
1444
|
+
|
1445
|
+
|
1446
|
+
Raises
|
1447
|
+
------
|
1448
|
+
ValueError
|
1449
|
+
If the Shiny App does have bookmarking enabled.
|
1450
|
+
|
1451
|
+
Returns
|
1452
|
+
-------
|
1453
|
+
:
|
1454
|
+
A callback to cancel the bookmarking hooks.
|
1455
|
+
"""
|
1456
|
+
|
1457
|
+
session = get_current_session()
|
1458
|
+
if session is None or session.is_stub_session():
|
1459
|
+
return BookmarkCancelCallback(lambda: None)
|
1460
|
+
|
1461
|
+
if session.bookmark.store == "disable":
|
1462
|
+
raise ValueError(
|
1463
|
+
"Bookmarking requires a `bookmark_store` to be set. "
|
1464
|
+
"Please set `bookmark_store=` in `shiny.App()` or `shiny.express.app_opts()."
|
1465
|
+
)
|
1466
|
+
|
1467
|
+
resolved_bookmark_id_str = str(self.id)
|
1468
|
+
resolved_bookmark_id_msgs_str = resolved_bookmark_id_str + "--msgs"
|
1469
|
+
get_state: Callable[[], Awaitable[Jsonifiable]]
|
1470
|
+
set_state: Callable[[Jsonifiable], Awaitable[None]]
|
1471
|
+
|
1472
|
+
# Retrieve get_state/set_state functions from the client
|
1473
|
+
if isinstance(client, ClientWithState):
|
1474
|
+
# Do client with state stuff here
|
1475
|
+
get_state = _utils.wrap_async(client.get_state)
|
1476
|
+
set_state = _utils.wrap_async(client.set_state)
|
1477
|
+
|
1478
|
+
elif is_chatlas_chat_client(client):
|
1479
|
+
get_state = get_chatlas_state(client)
|
1480
|
+
set_state = set_chatlas_state(client)
|
1481
|
+
|
1482
|
+
else:
|
1483
|
+
raise ValueError(
|
1484
|
+
"Bookmarking requires a client that supports "
|
1485
|
+
"`async def get_state(self) -> shiny.types.Jsonifiable` (which returns an object that can be used when bookmarking to save the state of the `client=`) and "
|
1486
|
+
"`async def set_state(self, value: Jsonifiable)` (which should restore the `client=`'s state given the `state=`)."
|
1487
|
+
)
|
1488
|
+
|
1489
|
+
# Reset prior bookmarking hooks
|
1490
|
+
self._destroy_bookmarking()
|
1491
|
+
|
1492
|
+
# Must use `root_session` as the id is already resolved. :-/
|
1493
|
+
# Using a proxy session would double-encode the proxy-prefix
|
1494
|
+
root_session = session.root_scope()
|
1495
|
+
root_session.bookmark.exclude.append(self.id + "_user_input")
|
1496
|
+
|
1497
|
+
# ###########
|
1498
|
+
# Bookmarking
|
1499
|
+
|
1500
|
+
if bookmark_on is not None:
|
1501
|
+
# When ever the bookmark is requested, update the query string (indep of store type)
|
1502
|
+
@root_session.bookmark.on_bookmarked
|
1503
|
+
async def _(url: str):
|
1504
|
+
await session.bookmark.update_query_string(url)
|
1505
|
+
|
1506
|
+
if bookmark_on == "response":
|
1507
|
+
|
1508
|
+
@reactive.effect
|
1509
|
+
@reactive.event(
|
1510
|
+
lambda: self.messages(format=MISSING), ignore_init=True
|
1511
|
+
)
|
1512
|
+
async def _():
|
1513
|
+
messages = self.messages(format=MISSING)
|
1514
|
+
|
1515
|
+
if len(messages) == 0:
|
1516
|
+
return
|
1517
|
+
|
1518
|
+
last_message = messages[-1]
|
1519
|
+
|
1520
|
+
if last_message.get("role") == "assistant":
|
1521
|
+
await session.bookmark()
|
1522
|
+
|
1523
|
+
###############
|
1524
|
+
# Client Bookmarking
|
1525
|
+
|
1526
|
+
@root_session.bookmark.on_bookmark
|
1527
|
+
async def _on_bookmark_client(state: BookmarkState):
|
1528
|
+
if resolved_bookmark_id_str in state.values:
|
1529
|
+
raise ValueError(
|
1530
|
+
f'Bookmark value with id (`"{resolved_bookmark_id_str}"`) already exists.'
|
1531
|
+
)
|
1532
|
+
|
1533
|
+
with reactive.isolate():
|
1534
|
+
state.values[resolved_bookmark_id_str] = await get_state()
|
1535
|
+
|
1536
|
+
@root_session.bookmark.on_restore
|
1537
|
+
async def _on_restore_client(state: RestoreState):
|
1538
|
+
if resolved_bookmark_id_str not in state.values:
|
1539
|
+
return
|
1540
|
+
|
1541
|
+
# Retrieve the chat turns from the bookmark state
|
1542
|
+
info = state.values[resolved_bookmark_id_str]
|
1543
|
+
await set_state(info)
|
1544
|
+
|
1545
|
+
###############
|
1546
|
+
# UI Bookmarking
|
1547
|
+
|
1548
|
+
@root_session.bookmark.on_bookmark
|
1549
|
+
def _on_bookmark_ui(state: BookmarkState):
|
1550
|
+
if resolved_bookmark_id_msgs_str in state.values:
|
1551
|
+
raise ValueError(
|
1552
|
+
f'Bookmark value with id (`"{resolved_bookmark_id_msgs_str}"`) already exists.'
|
1553
|
+
)
|
1554
|
+
|
1555
|
+
with reactive.isolate():
|
1556
|
+
# This does NOT contain the `chat.ui(messages=)` values.
|
1557
|
+
# When restoring, the `chat.ui(messages=)` values will need to be kept
|
1558
|
+
# and the `ui.Chat(messages=)` values will need to be reset
|
1559
|
+
state.values[resolved_bookmark_id_msgs_str] = self.messages(
|
1560
|
+
format=MISSING
|
1561
|
+
)
|
1562
|
+
|
1563
|
+
# Attempt to stop the initialization of the `ui.Chat(messages=)` messages
|
1564
|
+
self._init_chat.destroy()
|
1565
|
+
|
1566
|
+
@root_session.bookmark.on_restore
|
1567
|
+
async def _on_restore_ui(state: RestoreState):
|
1568
|
+
# Do not call `self.clear_messages()` as it will clear the
|
1569
|
+
# `chat.ui(messages=)` in addition to the `self.messages()`
|
1570
|
+
# (which is not what we want).
|
1571
|
+
|
1572
|
+
# We always want to keep the `chat.ui(messages=)` values
|
1573
|
+
# and `self.messages()` are never initialized due to
|
1574
|
+
# calling `self._init_chat.destroy()` above
|
1575
|
+
|
1576
|
+
if resolved_bookmark_id_msgs_str not in state.values:
|
1577
|
+
# If no messages to restore, display the `__init__(messages=)` messages
|
1578
|
+
await self._append_init_messages()
|
1579
|
+
return
|
1580
|
+
|
1581
|
+
msgs: list[Any] = state.values[resolved_bookmark_id_msgs_str]
|
1582
|
+
if not isinstance(msgs, list):
|
1583
|
+
raise ValueError(
|
1584
|
+
f"Bookmark value with id (`{resolved_bookmark_id_msgs_str}`) must be a list of messages."
|
1585
|
+
)
|
1586
|
+
|
1587
|
+
for message_dict in msgs:
|
1588
|
+
await self.append_message(message_dict)
|
1589
|
+
|
1590
|
+
def _cancel_bookmarking():
|
1591
|
+
_on_bookmark_client()
|
1592
|
+
_on_bookmark_ui()
|
1593
|
+
_on_restore_client()
|
1594
|
+
_on_restore_ui()
|
1595
|
+
|
1596
|
+
# Store the callbacks to be able to destroy them later
|
1597
|
+
self._cancel_bookmarking_callbacks = _cancel_bookmarking
|
1598
|
+
|
1599
|
+
return BookmarkCancelCallback(_cancel_bookmarking)
|
1600
|
+
|
1601
|
+
|
1602
|
+
class ChatExpress(Chat):
|
1603
|
+
def ui(
|
1604
|
+
self,
|
1605
|
+
*,
|
1606
|
+
messages: Optional[Sequence[str | ChatMessageDict]] = None,
|
1607
|
+
placeholder: str = "Enter a message...",
|
1608
|
+
width: CssUnit = "min(680px, 100%)",
|
1609
|
+
height: CssUnit = "auto",
|
1610
|
+
fill: bool = True,
|
1611
|
+
icon_assistant: HTML | Tag | TagList | None = None,
|
1612
|
+
**kwargs: TagAttrValue,
|
1613
|
+
) -> Tag:
|
1614
|
+
"""
|
1615
|
+
Create a UI element for this `Chat`.
|
1616
|
+
|
1617
|
+
Parameters
|
1618
|
+
----------
|
1619
|
+
messages
|
1620
|
+
A sequence of messages to display in the chat. Each message can be either a
|
1621
|
+
string or a dictionary with `content` and `role` keys. The `content` key
|
1622
|
+
should contain the message text, and the `role` key can be "assistant" or
|
1623
|
+
"user".
|
1624
|
+
placeholder
|
1625
|
+
Placeholder text for the chat input.
|
1626
|
+
width
|
1627
|
+
The width of the UI element.
|
1628
|
+
height
|
1629
|
+
The height of the UI element.
|
1630
|
+
fill
|
1631
|
+
Whether the chat should vertically take available space inside a fillable
|
1632
|
+
container.
|
1633
|
+
icon_assistant
|
1634
|
+
The icon to use for the assistant chat messages. Can be a HTML or a tag in
|
1635
|
+
the form of :class:`~htmltools.HTML` or :class:`~htmltools.Tag`. If `None`,
|
1636
|
+
a default robot icon is used.
|
1637
|
+
kwargs
|
1638
|
+
Additional attributes for the chat container element.
|
1639
|
+
"""
|
1640
|
+
|
1641
|
+
return chat_ui(
|
1642
|
+
id=self.id,
|
1643
|
+
messages=messages,
|
1644
|
+
placeholder=placeholder,
|
1645
|
+
width=width,
|
1646
|
+
height=height,
|
1647
|
+
fill=fill,
|
1648
|
+
icon_assistant=icon_assistant,
|
1649
|
+
**kwargs,
|
1650
|
+
)
|
1651
|
+
|
1652
|
+
def enable_bookmarking(
|
1653
|
+
self,
|
1654
|
+
client: ClientWithState | chatlas.Chat[Any, Any],
|
1655
|
+
/,
|
1656
|
+
*,
|
1657
|
+
bookmark_store: Optional[BookmarkStore] = None,
|
1658
|
+
bookmark_on: Optional[Literal["response"]] = "response",
|
1659
|
+
) -> CancelCallback:
|
1660
|
+
"""
|
1661
|
+
Enable bookmarking for the chat instance.
|
1662
|
+
|
1663
|
+
This method registers `on_bookmark` and `on_restore` hooks on `session.bookmark`
|
1664
|
+
(:class:`shiny.bookmark.Bookmark`) to save/restore chat state on both the `Chat`
|
1665
|
+
and `client=` instances. In order for this method to actually work correctly, a
|
1666
|
+
`bookmark_store=` must be specified in `shiny.express.app_opts()`.
|
1667
|
+
|
1668
|
+
Parameters
|
1669
|
+
----------
|
1670
|
+
client
|
1671
|
+
The chat client instance to use for bookmarking. This can be a Chat model
|
1672
|
+
provider from [chatlas](https://posit-dev.github.io/chatlas/), or more
|
1673
|
+
generally, an instance following the `ClientWithState` protocol.
|
1674
|
+
bookmark_store
|
1675
|
+
A convenience parameter to set the `shiny.express.app_opts(bookmark_store=)`
|
1676
|
+
which is required for bookmarking (and `.enable_bookmarking()`). If `None`,
|
1677
|
+
no value will be set.
|
1678
|
+
bookmark_on
|
1679
|
+
The event to trigger the bookmarking on. Supported values include:
|
1680
|
+
|
1681
|
+
- `"response"` (the default): a bookmark is triggered when the assistant is done responding.
|
1682
|
+
- `None`: no bookmark is triggered
|
1683
|
+
|
1684
|
+
When this method triggers a bookmark, it also updates the URL query string to reflect the bookmarked state.
|
1685
|
+
|
1686
|
+
Raises
|
1687
|
+
------
|
1688
|
+
ValueError
|
1689
|
+
If the Shiny App does have bookmarking enabled.
|
1690
|
+
|
1691
|
+
Returns
|
1692
|
+
-------
|
1693
|
+
:
|
1694
|
+
A callback to cancel the bookmarking hooks.
|
1695
|
+
"""
|
1696
|
+
|
1697
|
+
if bookmark_store is not None:
|
1698
|
+
from shiny.express import app_opts
|
1699
|
+
|
1700
|
+
app_opts(bookmark_store=bookmark_store)
|
1701
|
+
|
1702
|
+
return super().enable_bookmarking(client, bookmark_on=bookmark_on)
|
1703
|
+
|
1704
|
+
|
1705
|
+
def chat_ui(
|
1706
|
+
id: str,
|
1707
|
+
*,
|
1708
|
+
messages: Optional[Sequence[TagChild | ChatMessageDict]] = None,
|
1709
|
+
placeholder: str = "Enter a message...",
|
1710
|
+
width: CssUnit = "min(680px, 100%)",
|
1711
|
+
height: CssUnit = "auto",
|
1712
|
+
fill: bool = True,
|
1713
|
+
icon_assistant: HTML | Tag | TagList | None = None,
|
1714
|
+
**kwargs: TagAttrValue,
|
1715
|
+
) -> Tag:
|
1716
|
+
"""
|
1717
|
+
UI container for a chat component (Shiny Core).
|
1718
|
+
|
1719
|
+
This function is for locating a :class:`~shiny.ui.Chat` instance in a Shiny Core
|
1720
|
+
app. If you are using Shiny Express, use the :method:`~shiny.ui.Chat.ui` method
|
1721
|
+
instead.
|
1722
|
+
|
1723
|
+
Parameters
|
1724
|
+
----------
|
1725
|
+
id
|
1726
|
+
A unique identifier for the chat UI.
|
1727
|
+
messages
|
1728
|
+
A sequence of messages to display in the chat. A given message can be one of the
|
1729
|
+
following:
|
1730
|
+
|
1731
|
+
* A string, which is interpreted as markdown and rendered to HTML on the client.
|
1732
|
+
* To prevent interpreting as markdown, mark the string as
|
1733
|
+
:class:`~shiny.ui.HTML`.
|
1734
|
+
* A UI element (specifically, a :class:`~shiny.ui.TagChild`).
|
1735
|
+
* This includes :class:`~shiny.ui.TagList`, which take UI elements
|
1736
|
+
(including strings) as children. In this case, strings are still
|
1737
|
+
interpreted as markdown as long as they're not inside HTML.
|
1738
|
+
* A dictionary with `content` and `role` keys. The `content` key can contain a
|
1739
|
+
content as described above, and the `role` key can be "assistant" or "user".
|
1740
|
+
|
1741
|
+
**NOTE:** content may include specially formatted **input suggestion** links
|
1742
|
+
(see :method:`~shiny.ui.Chat.append_message` for more info).
|
1743
|
+
placeholder
|
1744
|
+
Placeholder text for the chat input.
|
1745
|
+
width
|
1746
|
+
The width of the chat container.
|
1747
|
+
height
|
1748
|
+
The height of the chat container.
|
1749
|
+
fill
|
1750
|
+
Whether the chat should vertically take available space inside a fillable container.
|
1751
|
+
icon_assistant
|
1752
|
+
The icon to use for the assistant chat messages. Can be a HTML or a tag in
|
1753
|
+
the form of :class:`~htmltools.HTML` or :class:`~htmltools.Tag`. If `None`,
|
1754
|
+
a default robot icon is used.
|
1755
|
+
kwargs
|
1756
|
+
Additional attributes for the chat container element.
|
1757
|
+
"""
|
1758
|
+
|
1759
|
+
id = resolve_id(id)
|
1760
|
+
|
1761
|
+
icon_attr = None
|
1762
|
+
if icon_assistant is not None:
|
1763
|
+
icon_attr = str(icon_assistant)
|
1764
|
+
|
1765
|
+
icon_deps = None
|
1766
|
+
if isinstance(icon_assistant, (Tag, TagList)):
|
1767
|
+
icon_deps = icon_assistant.get_dependencies()
|
1768
|
+
|
1769
|
+
message_tags: list[Tag] = []
|
1770
|
+
if messages is None:
|
1771
|
+
messages = []
|
1772
|
+
for x in messages:
|
1773
|
+
role = "assistant"
|
1774
|
+
content: TagChild = None
|
1775
|
+
if not isinstance(x, dict):
|
1776
|
+
content = x
|
1777
|
+
else:
|
1778
|
+
if "content" not in x:
|
1779
|
+
raise ValueError(
|
1780
|
+
"Each message dictionary must have a 'content' key."
|
1781
|
+
)
|
1782
|
+
|
1783
|
+
content = x["content"]
|
1784
|
+
if "role" in x:
|
1785
|
+
role = x["role"]
|
1786
|
+
|
1787
|
+
# `content` is most likely a string, so avoid overhead in that case
|
1788
|
+
# (it's also important that we *don't escape HTML* here).
|
1789
|
+
if isinstance(content, str):
|
1790
|
+
ui: RenderedHTML = {"html": content, "dependencies": []}
|
1791
|
+
else:
|
1792
|
+
ui = TagList(content).render()
|
1793
|
+
|
1794
|
+
if role == "user":
|
1795
|
+
tag_name = "shiny-user-message"
|
1796
|
+
else:
|
1797
|
+
tag_name = "shiny-chat-message"
|
1798
|
+
|
1799
|
+
message_tags.append(
|
1800
|
+
Tag(
|
1801
|
+
tag_name,
|
1802
|
+
ui["dependencies"],
|
1803
|
+
content=ui["html"],
|
1804
|
+
icon=icon_attr,
|
1805
|
+
)
|
1806
|
+
)
|
1807
|
+
|
1808
|
+
res = Tag(
|
1809
|
+
"shiny-chat-container",
|
1810
|
+
Tag("shiny-chat-messages", *message_tags),
|
1811
|
+
Tag(
|
1812
|
+
"shiny-chat-input",
|
1813
|
+
id=f"{id}_user_input",
|
1814
|
+
placeholder=placeholder,
|
1815
|
+
),
|
1816
|
+
chat_deps(),
|
1817
|
+
icon_deps,
|
1818
|
+
{
|
1819
|
+
"style": css(
|
1820
|
+
width=as_css_unit(width),
|
1821
|
+
height=as_css_unit(height),
|
1822
|
+
)
|
1823
|
+
},
|
1824
|
+
id=id,
|
1825
|
+
placeholder=placeholder,
|
1826
|
+
fill=fill,
|
1827
|
+
# Also include icon on the parent so that when messages are dynamically added,
|
1828
|
+
# we know the default icon has changed
|
1829
|
+
icon_assistant=icon_attr,
|
1830
|
+
**kwargs,
|
1831
|
+
)
|
1832
|
+
|
1833
|
+
if fill:
|
1834
|
+
res = as_fillable_container(as_fill_item(res))
|
1835
|
+
|
1836
|
+
return res
|
1837
|
+
|
1838
|
+
|
1839
|
+
class MessageStream:
|
1840
|
+
"""
|
1841
|
+
An object to yield from a `.message_stream_context()` context manager.
|
1842
|
+
"""
|
1843
|
+
|
1844
|
+
def __init__(self, chat: Chat, stream_id: str):
|
1845
|
+
self._chat = chat
|
1846
|
+
self._stream_id = stream_id
|
1847
|
+
|
1848
|
+
async def replace(self, message_chunk: Any):
|
1849
|
+
"""
|
1850
|
+
Replace the content of the stream with new content.
|
1851
|
+
|
1852
|
+
Parameters
|
1853
|
+
-----------
|
1854
|
+
message_chunk
|
1855
|
+
The new content to replace the current content.
|
1856
|
+
"""
|
1857
|
+
await self._chat._append_message_chunk(
|
1858
|
+
message_chunk,
|
1859
|
+
operation="replace",
|
1860
|
+
stream_id=self._stream_id,
|
1861
|
+
)
|
1862
|
+
|
1863
|
+
async def append(self, message_chunk: Any):
|
1864
|
+
"""
|
1865
|
+
Append a message chunk to the stream.
|
1866
|
+
|
1867
|
+
Parameters
|
1868
|
+
-----------
|
1869
|
+
message_chunk
|
1870
|
+
A message chunk to append to this stream
|
1871
|
+
"""
|
1872
|
+
await self._chat._append_message_chunk(
|
1873
|
+
message_chunk,
|
1874
|
+
stream_id=self._stream_id,
|
1875
|
+
)
|
1876
|
+
|
1877
|
+
|
1878
|
+
CHAT_INSTANCES: WeakValueDictionary[str, Chat] = WeakValueDictionary()
|