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/_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()