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.
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal, TypedDict
5
+
6
+ from htmltools import HTML, TagChild
7
+ from shiny.session import require_active_session
8
+
9
+ from ._typing_extensions import NotRequired
10
+
11
+ Role = Literal["assistant", "user", "system"]
12
+
13
+
14
+ # TODO: content should probably be [{"type": "text", "content": "..."}, {"type": "image", ...}]
15
+ # in order to support multiple content types...
16
+ class ChatMessageDict(TypedDict):
17
+ content: str
18
+ role: Role
19
+
20
+
21
+ class ChatMessage:
22
+ def __init__(
23
+ self,
24
+ content: TagChild,
25
+ role: Role,
26
+ ):
27
+ self.role: Role = role
28
+
29
+ # content _can_ be a TagChild, but it's most likely just a string (of
30
+ # markdown), so only process it if it's not a string.
31
+ deps = []
32
+ if not isinstance(content, str):
33
+ session = require_active_session(None)
34
+ res = session._process_ui(content)
35
+ content = res["html"]
36
+ deps = res["deps"]
37
+
38
+ self.content = content
39
+ self.html_deps = deps
40
+
41
+
42
+ # A message once transformed have been applied
43
+ @dataclass
44
+ class TransformedMessage:
45
+ content_client: str | HTML
46
+ content_server: str
47
+ role: Role
48
+ transform_key: Literal["content_client", "content_server"]
49
+ pre_transform_key: Literal["content_client", "content_server"]
50
+ html_deps: list[dict[str, str]] | None = None
51
+
52
+ @classmethod
53
+ def from_chat_message(cls, message: ChatMessage) -> "TransformedMessage":
54
+ if message.role == "user":
55
+ transform_key = "content_server"
56
+ pre_transform_key = "content_client"
57
+ else:
58
+ transform_key = "content_client"
59
+ pre_transform_key = "content_server"
60
+
61
+ return TransformedMessage(
62
+ content_client=message.content,
63
+ content_server=message.content,
64
+ role=message.role,
65
+ transform_key=transform_key,
66
+ pre_transform_key=pre_transform_key,
67
+ html_deps=message.html_deps,
68
+ )
69
+
70
+
71
+ # A message that can be sent to the client
72
+ class ClientMessage(TypedDict):
73
+ content: str
74
+ role: Literal["assistant", "user"]
75
+ content_type: Literal["markdown", "html"]
76
+ chunk_type: Literal["message_start", "message_end"] | None
77
+ operation: Literal["append", "replace"]
78
+ icon: NotRequired[str]
79
+ html_deps: NotRequired[list[dict[str, str]]]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from htmltools import HTMLDependency
4
+
5
+ from .__version import __version__
6
+
7
+ """
8
+ HTML dependencies for internal dependencies such as dataframe.
9
+
10
+ For...
11
+ * External dependencies (e.g. jQuery, Bootstrap), see `shiny.ui._html_deps_external`
12
+ * Internal dependencies (e.g. dataframe), see `shiny.ui._html_deps_py_shiny`
13
+ * shinyverse dependencies (e.g. bslib, htmltools), see `shiny.ui._html_deps_shinyverse`
14
+ """
15
+
16
+
17
+ def chat_deps() -> list[HTMLDependency]:
18
+ dep = HTMLDependency(
19
+ name="shinychat-chat",
20
+ version=__version__,
21
+ source={
22
+ "package": "shinychat",
23
+ "subdir": "www/chat",
24
+ },
25
+ script={"src": "chat.js", "type": "module"},
26
+ stylesheet={"href": "chat.css"},
27
+ )
28
+ return [dep, markdown_stream_dependency()]
29
+
30
+
31
+ def markdown_stream_dependency() -> HTMLDependency:
32
+ return HTMLDependency(
33
+ name="shinychat-markdown",
34
+ version=__version__,
35
+ source={
36
+ "package": "shinychat",
37
+ "subdir": "www/markdown-stream",
38
+ },
39
+ script={"src": "markdown-stream.js", "type": "module"},
40
+ stylesheet={"href": "markdown-stream.css"},
41
+ )
@@ -0,0 +1,374 @@
1
+ from contextlib import asynccontextmanager
2
+ from typing import AsyncIterable, Iterable, Literal, Union
3
+
4
+ from htmltools import RenderedHTML, Tag, TagChild, TagList, css
5
+ from shiny import _utils, reactive
6
+ from shiny._deprecated import warn_deprecated
7
+ from shiny.module import resolve_id
8
+ from shiny.session import require_active_session, session_context
9
+ from shiny.session._utils import RenderedDeps
10
+ from shiny.types import NotifyException
11
+ from shiny.ui.css import CssUnit, as_css_unit
12
+
13
+ from ._html_deps_py_shiny import markdown_stream_dependency
14
+ from ._typing_extensions import TypedDict
15
+
16
+ __all__ = (
17
+ "output_markdown_stream",
18
+ "MarkdownStream",
19
+ "ExpressMarkdownStream",
20
+ )
21
+
22
+ StreamingContentType = Literal[
23
+ "markdown",
24
+ "html",
25
+ "semi-markdown",
26
+ "text",
27
+ ]
28
+
29
+
30
+ class ContentMessage(TypedDict):
31
+ id: str
32
+ content: str
33
+ operation: Literal["append", "replace"]
34
+ html_deps: list[dict[str, str]]
35
+
36
+
37
+ class isStreamingMessage(TypedDict):
38
+ id: str
39
+ isStreaming: bool
40
+
41
+
42
+ class MarkdownStream:
43
+ """
44
+ A component for streaming markdown or HTML content.
45
+
46
+ Parameters
47
+ ----------
48
+ id
49
+ A unique identifier for this `MarkdownStream`. In Shiny Core, make sure this id
50
+ matches a corresponding :func:`~shiny.ui.output_markdown_stream` call in the app's
51
+ UI.
52
+ on_error
53
+ How to handle errors that occur while streaming. When `"unhandled"`,
54
+ the app will stop running when an error occurs. Otherwise, a notification
55
+ is displayed to the user and the app continues to run.
56
+
57
+ * `"auto"`: Sanitize the error message if the app is set to sanitize errors,
58
+ otherwise display the actual error message.
59
+ * `"actual"`: Display the actual error message to the user.
60
+ * `"sanitize"`: Sanitize the error message before displaying it to the user.
61
+ * `"unhandled"`: Do not display any error message to the user.
62
+
63
+ Note
64
+ ----
65
+ Markdown is parsed on the client via `marked.js`. Consider using
66
+ :func:`~shiny.ui.markdown` for server-side rendering of markdown content.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ id: str,
72
+ *,
73
+ on_error: Literal["auto", "actual", "sanitize", "unhandled"] = "auto",
74
+ ):
75
+ self.id = resolve_id(id)
76
+ # TODO: remove the `None` when this PR lands:
77
+ # https://github.com/posit-dev/py-shiny/pull/793/files
78
+ self._session = require_active_session(None)
79
+
80
+ # Default to sanitizing until we know the app isn't sanitizing errors
81
+ if on_error == "auto":
82
+ on_error = "sanitize"
83
+ app = self._session.app
84
+ if app is not None and not app.sanitize_errors: # type: ignore
85
+ on_error = "actual"
86
+
87
+ self.on_error = on_error
88
+
89
+ with session_context(self._session):
90
+
91
+ @reactive.extended_task
92
+ async def _mock_task() -> str:
93
+ return ""
94
+
95
+ self._latest_stream: reactive.Value[reactive.ExtendedTask[[], str]] = (
96
+ reactive.Value(_mock_task)
97
+ )
98
+
99
+ async def stream(
100
+ self,
101
+ content: Union[Iterable[TagChild], AsyncIterable[TagChild]],
102
+ clear: bool = True,
103
+ ):
104
+ """
105
+ Send a stream of content to the UI.
106
+
107
+ Stream content into the relevant UI element.
108
+
109
+ Parameters
110
+ ----------
111
+ content
112
+ The content to stream. This can be a Iterable or an AsyncIterable of strings.
113
+ Note that this includes synchronous and asynchronous generators, which is
114
+ a useful way to stream content in as it arrives (e.g. from a LLM).
115
+ clear
116
+ Whether to clear the existing content before streaming the new content.
117
+
118
+ Note
119
+ ----
120
+ If you already have the content available as a string, you can do
121
+ `.stream([content])` to set the content.
122
+
123
+ Returns
124
+ -------
125
+ :
126
+ An extended task that represents the streaming task. The `.result()` method
127
+ of the task can be called in a reactive context to get the final state of the
128
+ stream.
129
+ """
130
+
131
+ content = _utils.wrap_async_iterable(content)
132
+
133
+ @reactive.extended_task
134
+ async def _task():
135
+ if clear:
136
+ await self._send_content_message("", "replace", [])
137
+
138
+ result = ""
139
+ async with self._streaming_dot():
140
+ async for x in content:
141
+ if isinstance(x, str):
142
+ # x is most likely a string, so avoid overhead in that case
143
+ ui: RenderedDeps = {"html": x, "deps": []}
144
+ else:
145
+ # process_ui() does *not* render markdown->HTML, but it does:
146
+ # 1. Extract and register HTMLdependency()s with the session.
147
+ # 2. Returns a HTML string representation of the TagChild
148
+ # (i.e., `div()` -> `"<div>"`).
149
+ ui = self._session._process_ui(x)
150
+
151
+ result += ui["html"]
152
+ await self._send_content_message(ui["html"], "append", ui["deps"])
153
+
154
+ return result
155
+
156
+ _task()
157
+
158
+ self._latest_stream.set(_task)
159
+
160
+ # Since the task runs in the background (outside/beyond the current context,
161
+ # if any), we need to manually raise any exceptions that occur
162
+ @reactive.effect
163
+ async def _handle_error():
164
+ e = _task.error()
165
+ if e:
166
+ await self._raise_exception(e)
167
+ _handle_error.destroy() # type: ignore
168
+
169
+ return _task
170
+
171
+ @property
172
+ def latest_stream(self):
173
+ """
174
+ React to changes in the latest stream.
175
+
176
+ Reactively reads for the :class:`~shiny.reactive.ExtendedTask` behind the
177
+ latest stream.
178
+
179
+ From the return value (i.e., the extended task), you can then:
180
+
181
+ 1. Reactively read for the final `.result()`.
182
+ 2. `.cancel()` the stream.
183
+ 3. Check the `.status()` of the stream.
184
+
185
+ Returns
186
+ -------
187
+ :
188
+ An extended task that represents the streaming task. The `.result()` method
189
+ of the task can be called in a reactive context to get the final state of the
190
+ stream.
191
+
192
+ Note
193
+ ----
194
+ If no stream has yet been started when this method is called, then it returns an
195
+ extended task with `.status()` of `"initial"` and that it status doesn't change
196
+ state until a message is streamed.
197
+ """
198
+ return self._latest_stream()
199
+
200
+ def get_latest_stream_result(self) -> Union[str, None]:
201
+ """
202
+ Reactively read the latest stream result.
203
+
204
+ Deprecated. Use `latest_stream.result()` instead.
205
+ """
206
+ warn_deprecated(
207
+ "The `.get_latest_stream_result()` method is deprecated and will be removed "
208
+ "in a future release. Use `.latest_stream.result()` instead. "
209
+ )
210
+ return self.latest_stream.result()
211
+
212
+ async def clear(self):
213
+ """
214
+ Empty the UI element of the `MarkdownStream`.
215
+ """
216
+ return await self.stream([], clear=True)
217
+
218
+ @asynccontextmanager
219
+ async def _streaming_dot(self):
220
+ await self._send_stream_message(True)
221
+ try:
222
+ yield
223
+ finally:
224
+ await self._send_stream_message(False)
225
+
226
+ async def _send_content_message(
227
+ self,
228
+ content: str,
229
+ operation: Literal["append", "replace"],
230
+ html_deps: list[dict[str, str]],
231
+ ):
232
+ msg: ContentMessage = {
233
+ "id": self.id,
234
+ "content": content,
235
+ "operation": operation,
236
+ "html_deps": html_deps,
237
+ }
238
+ await self._send_custom_message(msg)
239
+
240
+ async def _send_stream_message(self, is_streaming: bool):
241
+ msg: isStreamingMessage = {
242
+ "id": self.id,
243
+ "isStreaming": is_streaming,
244
+ }
245
+ await self._send_custom_message(msg)
246
+
247
+ async def _send_custom_message(
248
+ self, msg: Union[ContentMessage, isStreamingMessage]
249
+ ):
250
+ if self._session.is_stub_session():
251
+ return
252
+ await self._session.send_custom_message("shinyMarkdownStreamMessage", {**msg})
253
+
254
+ async def _raise_exception(self, e: BaseException):
255
+ if self.on_error == "unhandled":
256
+ raise e
257
+ else:
258
+ sanitize = self.on_error == "sanitize"
259
+ msg = f"Error in MarkdownStream('{self.id}'): {str(e)}"
260
+ raise NotifyException(msg, sanitize=sanitize) from e
261
+
262
+
263
+ class ExpressMarkdownStream(MarkdownStream):
264
+ def ui(
265
+ self,
266
+ *,
267
+ content: TagChild = "",
268
+ content_type: StreamingContentType = "markdown",
269
+ auto_scroll: bool = True,
270
+ width: CssUnit = "min(680px, 100%)",
271
+ height: CssUnit = "auto",
272
+ ) -> Tag:
273
+ """
274
+ Create a UI element for this `MarkdownStream`.
275
+
276
+ Parameters
277
+ ----------
278
+ content
279
+ A string of content to display before any streaming occurs. When
280
+ `content_type` is Markdown or HTML, it may also be UI element(s) such as
281
+ input and output bindings.
282
+ content_type
283
+ The content type. Default is `"markdown"` (specifically, CommonMark).
284
+ Supported content types include:
285
+ - `"markdown"`: markdown text, specifically CommonMark
286
+ - `"html"`: for rendering HTML content.
287
+ - `"text"`: for plain text.
288
+ - `"semi-markdown"`: for rendering markdown, but with HTML tags escaped.
289
+ auto_scroll
290
+ Whether to automatically scroll to the bottom of a scrollable container
291
+ when new content is added. Default is `True`.
292
+ width
293
+ The width of the UI element.
294
+ height
295
+ The height of the UI element.
296
+
297
+ Returns
298
+ -------
299
+ Tag
300
+ A UI element for locating the `MarkdownStream` in the app.
301
+ """
302
+ return output_markdown_stream(
303
+ self.id,
304
+ content=content,
305
+ content_type=content_type,
306
+ auto_scroll=auto_scroll,
307
+ width=width,
308
+ height=height,
309
+ )
310
+
311
+
312
+ def output_markdown_stream(
313
+ id: str,
314
+ *,
315
+ content: TagChild = "",
316
+ content_type: StreamingContentType = "markdown",
317
+ auto_scroll: bool = True,
318
+ width: CssUnit = "min(680px, 100%)",
319
+ height: CssUnit = "auto",
320
+ ) -> Tag:
321
+ """
322
+ Create a UI element for a :class:`~shiny.ui.MarkdownStream`.
323
+
324
+ This function is only relevant for Shiny Core. In Shiny Express, use
325
+ :meth:`~shiny.express.ui.MarkdownStream.ui` to create the UI element.
326
+
327
+ Parameters
328
+ ----------
329
+ id
330
+ A unique identifier for the UI element. This id should match the id of the
331
+ :class:`~shiny.ui.MarkdownStream` instance.
332
+ content
333
+ A string of content to display before any streaming occurs. When `content_type`
334
+ is Markdown or HTML, it may also be UI element(s) such as input and output
335
+ bindings.
336
+ content_type
337
+ The content type. Default is "markdown" (specifically, CommonMark). Supported
338
+ content types include:
339
+ - `"markdown"`: markdown text, specifically CommonMark
340
+ - `"html"`: for rendering HTML content.
341
+ - `"text"`: for plain text.
342
+ - `"semi-markdown"`: for rendering markdown, but with HTML tags escaped.
343
+ auto_scroll
344
+ Whether to automatically scroll to the bottom of a scrollable container
345
+ when new content is added. Default is True.
346
+ width
347
+ The width of the UI element.
348
+ height
349
+ The height of the UI element.
350
+ """
351
+
352
+ # `content` is most likely a string, so avoid overhead in that case
353
+ # (it's also important that we *don't escape HTML* here).
354
+ if isinstance(content, str):
355
+ ui: RenderedHTML = {"html": content, "dependencies": []}
356
+ else:
357
+ ui = TagList(content).render()
358
+
359
+ return Tag(
360
+ "shiny-markdown-stream",
361
+ markdown_stream_dependency(),
362
+ ui["dependencies"],
363
+ {
364
+ "style": css(
365
+ width=as_css_unit(width),
366
+ height=as_css_unit(height),
367
+ margin="0 auto",
368
+ ),
369
+ "content-type": content_type,
370
+ "auto-scroll": "" if auto_scroll else None,
371
+ },
372
+ id=resolve_id(id),
373
+ content=ui["html"],
374
+ )
@@ -0,0 +1,63 @@
1
+ # # Within file flags to ignore unused imports
2
+ # flake8: noqa: F401
3
+ # pyright: reportUnusedImport=false
4
+
5
+ __all__ = (
6
+ "Annotated",
7
+ "Concatenate",
8
+ "ParamSpec",
9
+ "TypeGuard",
10
+ "TypeIs",
11
+ "Never",
12
+ "Required",
13
+ "NotRequired",
14
+ "Self",
15
+ "TypedDict",
16
+ "assert_type",
17
+ )
18
+
19
+
20
+ import sys
21
+
22
+ if sys.version_info >= (3, 9):
23
+ from typing import Annotated
24
+ else:
25
+ from typing_extensions import Annotated
26
+
27
+ if sys.version_info >= (3, 10):
28
+ from typing import Concatenate, ParamSpec, TypeGuard
29
+ else:
30
+ from typing_extensions import Concatenate, ParamSpec, TypeGuard
31
+
32
+ # Even though TypedDict is available in Python 3.8, because it's used with NotRequired,
33
+ # they should both come from the same typing module.
34
+ # https://peps.python.org/pep-0655/#usage-in-python-3-11
35
+ if sys.version_info >= (3, 11):
36
+ from typing import (
37
+ Never,
38
+ NotRequired,
39
+ Required,
40
+ Self,
41
+ TypedDict,
42
+ assert_type,
43
+ )
44
+ else:
45
+ from typing_extensions import (
46
+ Never,
47
+ NotRequired,
48
+ Required,
49
+ Self,
50
+ TypedDict,
51
+ assert_type,
52
+ )
53
+
54
+ if sys.version_info >= (3, 13):
55
+ from typing import TypeIs
56
+ else:
57
+ from typing_extensions import TypeIs
58
+
59
+ # The only purpose of the following line is so that pyright will put all of the
60
+ # conditional imports into the .pyi file when generating type stubs. Without this line,
61
+ # pyright will not include the above imports in the generated .pyi file, and it will
62
+ # result in a lot of red squiggles in user code.
63
+ _: 'Annotated |Concatenate[str, ParamSpec("P")] | ParamSpec | TypeGuard | TypeIs | NotRequired | Required | TypedDict | assert_type | Self' # type:ignore