Skip to content

Commit 2c04d89

Browse files
gadenbuiecpsievert
andauthored
feat: Configurable chat icon (#1853)
Co-authored-by: Carson <cpsievert1@gmail.com>
1 parent c4500de commit 2c04d89

File tree

14 files changed

+6518
-32
lines changed

14 files changed

+6518
-32
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131

3232
* The `ui.Chat()` component's `.update_user_input()` method gains `submit` and `focus` options that allow you to submit the input on behalf of the user and to choose whether the input receives focus after the update. (#1851)
3333

34+
* The assistant icons is now configurable via `ui.chat_ui()` (or the `ui.Chat.ui()` method in Shiny Express) or for individual messages in the `.append_message()` and `.append_message_stream()` methods of `ui.Chat()`. (#1853)
35+
3436
### Bug fixes
3537

3638
* `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)

js/chat/chat.scss

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,30 @@ shiny-chat-message {
7373
.message-icon {
7474
border-radius: 50%;
7575
border: var(--shiny-chat-border);
76+
height: 2rem;
77+
width: 2rem;
78+
display: grid;
79+
place-items: center;
80+
overflow: clip;
81+
7682
> * {
77-
margin: 0.5rem;
78-
height: 20px;
79-
width: 20px;
83+
// images and avatars are full-bleed
84+
height: 100%;
85+
width: 100%;
86+
margin: 0 !important;
87+
object-fit: contain;
88+
}
89+
90+
> svg,
91+
> .icon,
92+
> .fa,
93+
> .bi {
94+
// icons and svgs need some padding within the circle
95+
max-height: 66%;
96+
max-width: 66%;
8097
}
8198
}
99+
82100
/* Vertically center the 2nd column (message content) */
83101
shiny-markdown-stream {
84102
align-self: center;

js/chat/chat.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type Message = {
1515
role: "user" | "assistant";
1616
chunk_type: "message_start" | "message_end" | null;
1717
content_type: ContentType;
18+
icon?: string;
1819
operation: "append" | null;
1920
};
2021
type ShinyChatMessage = {
@@ -60,10 +61,12 @@ class ChatMessage extends LightElement {
6061
@property() content = "...";
6162
@property() content_type: ContentType = "markdown";
6263
@property({ type: Boolean, reflect: true }) streaming = false;
64+
@property() icon = "";
6365

6466
render() {
65-
const noContent = this.content.trim().length === 0;
66-
const icon = noContent ? ICONS.dots_fade : ICONS.robot;
67+
// Show dots until we have content
68+
const isEmpty = this.content.trim().length === 0;
69+
const icon = isEmpty ? ICONS.dots_fade : this.icon || ICONS.robot;
6770

6871
return html`
6972
<div class="message-icon">${unsafeHTML(icon)}</div>
@@ -262,6 +265,7 @@ class ChatInput extends LightElement {
262265
}
263266

264267
class ChatContainer extends LightElement {
268+
@property({ attribute: "icon-assistant" }) iconAssistant = "";
265269
inputSentinelObserver?: IntersectionObserver;
266270

267271
private get input(): ChatInput {
@@ -381,6 +385,11 @@ class ChatContainer extends LightElement {
381385

382386
const TAG_NAME =
383387
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;
388+
389+
if (this.iconAssistant) {
390+
message.icon = message.icon || this.iconAssistant;
391+
}
392+
384393
const msg = createElement(TAG_NAME, message);
385394
this.messages.appendChild(msg);
386395

shiny/ui/_chat.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818
from weakref import WeakValueDictionary
1919

20-
from htmltools import HTML, Tag, TagAttrValue, css
20+
from htmltools import HTML, Tag, TagAttrValue, TagList, css
2121

2222
from .. import _utils, reactive
2323
from .._deprecated import warn_deprecated
@@ -493,7 +493,12 @@ def messages(
493493

494494
return tuple(res)
495495

496-
async def append_message(self, message: Any) -> None:
496+
async def append_message(
497+
self,
498+
message: Any,
499+
*,
500+
icon: HTML | Tag | TagList | None = None,
501+
):
497502
"""
498503
Append a message to the chat.
499504
@@ -506,10 +511,14 @@ async def append_message(self, message: Any) -> None:
506511
Content strings are interpreted as markdown and rendered to HTML on the
507512
client. Content may also include specially formatted **input suggestion**
508513
links (see note below).
514+
icon
515+
An optional icon to display next to the message, currently only used for
516+
assistant messages. The icon can be any HTML element (e.g., an
517+
:func:`~shiny.ui.img` tag) or a string of HTML.
509518
510519
Note
511520
----
512-
``{.callout-note title="Input suggestions"}
521+
:::{.callout-note title="Input suggestions"}
513522
Input suggestions are special links that send text to the user input box when
514523
clicked (or accessed via keyboard). They can be created in the following ways:
515524
@@ -528,17 +537,22 @@ async def append_message(self, message: Any) -> None:
528537
529538
Note that a user may also opt-out of submitting a suggestion by holding the
530539
`Alt/Option` key while clicking the suggestion link.
531-
```
540+
:::
532541
533-
```{.callout-note title="Streamed messages"}
542+
:::{.callout-note title="Streamed messages"}
534543
Use `.append_message_stream()` instead of this method when `stream=True` (or
535544
similar) is specified in model's completion method.
536-
```
545+
:::
537546
"""
538-
await self._append_message(message)
547+
await self._append_message(message, icon=icon)
539548

540549
async def _append_message(
541-
self, message: Any, *, chunk: ChunkOption = False, stream_id: str | None = None
550+
self,
551+
message: Any,
552+
*,
553+
chunk: ChunkOption = False,
554+
stream_id: str | None = None,
555+
icon: HTML | Tag | TagList | None = None,
542556
) -> None:
543557
# If currently we're in a stream, handle other messages (outside the stream) later
544558
if not self._can_append_message(stream_id):
@@ -568,9 +582,18 @@ async def _append_message(
568582
if msg is None:
569583
return
570584
self._store_message(msg, chunk=chunk)
571-
await self._send_append_message(msg, chunk=chunk)
585+
await self._send_append_message(
586+
msg,
587+
chunk=chunk,
588+
icon=icon,
589+
)
572590

573-
async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any]):
591+
async def append_message_stream(
592+
self,
593+
message: Iterable[Any] | AsyncIterable[Any],
594+
*,
595+
icon: HTML | Tag | None = None,
596+
):
574597
"""
575598
Append a message as a stream of message chunks.
576599
@@ -583,6 +606,10 @@ async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any
583606
OpenAI, Anthropic, Ollama, and others. Content strings are interpreted as
584607
markdown and rendered to HTML on the client. Content may also include
585608
specially formatted **input suggestion** links (see note below).
609+
icon
610+
An optional icon to display next to the message, currently only used for
611+
assistant messages. The icon can be any HTML element (e.g., an
612+
:func:`~shiny.ui.img` tag) or a string of HTML.
586613
587614
Note
588615
----
@@ -625,7 +652,7 @@ async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any
625652
# Run the stream in the background to get non-blocking behavior
626653
@reactive.extended_task
627654
async def _stream_task():
628-
return await self._append_message_stream(message)
655+
return await self._append_message_stream(message, icon=icon)
629656

630657
_stream_task()
631658

@@ -669,11 +696,15 @@ def get_latest_stream_result(self) -> str | None:
669696
else:
670697
return stream.result()
671698

672-
async def _append_message_stream(self, message: AsyncIterable[Any]):
699+
async def _append_message_stream(
700+
self,
701+
message: AsyncIterable[Any],
702+
icon: HTML | Tag | None = None,
703+
):
673704
id = _utils.private_random_id()
674705

675706
empty = ChatMessage(content="", role="assistant")
676-
await self._append_message(empty, chunk="start", stream_id=id)
707+
await self._append_message(empty, chunk="start", stream_id=id, icon=icon)
677708

678709
try:
679710
async for msg in message:
@@ -702,6 +733,7 @@ async def _send_append_message(
702733
self,
703734
message: TransformedMessage,
704735
chunk: ChunkOption = False,
736+
icon: HTML | Tag | TagList | None = None,
705737
):
706738
if message["role"] == "system":
707739
# System messages are not displayed in the UI
@@ -721,13 +753,17 @@ async def _send_append_message(
721753
content = message["content_client"]
722754
content_type = "html" if isinstance(content, HTML) else "markdown"
723755

756+
# TODO: pass along dependencies for both content and icon (if any)
724757
msg = ClientMessage(
725758
content=str(content),
726759
role=message["role"],
727760
content_type=content_type,
728761
chunk_type=chunk_type,
729762
)
730763

764+
if icon is not None:
765+
msg["icon"] = str(icon)
766+
731767
# print(msg)
732768

733769
await self._send_custom_message(msg_type, msg)
@@ -1118,7 +1154,6 @@ async def _send_custom_message(self, handler: str, obj: ClientMessage | None):
11181154

11191155
@add_example(ex_dir="../templates/chat/starters/hello")
11201156
class ChatExpress(Chat):
1121-
11221157
def ui(
11231158
self,
11241159
*,
@@ -1127,6 +1162,7 @@ def ui(
11271162
width: CssUnit = "min(680px, 100%)",
11281163
height: CssUnit = "auto",
11291164
fill: bool = True,
1165+
icon_assistant: HTML | Tag | TagList | None = None,
11301166
**kwargs: TagAttrValue,
11311167
) -> Tag:
11321168
"""
@@ -1148,6 +1184,10 @@ def ui(
11481184
fill
11491185
Whether the chat should vertically take available space inside a fillable
11501186
container.
1187+
icon_assistant
1188+
The icon to use for the assistant chat messages. Can be a HTML or a tag in
1189+
the form of :class:`~htmltools.HTML` or :class:`~htmltools.Tag`. If `None`,
1190+
a default robot icon is used.
11511191
kwargs
11521192
Additional attributes for the chat container element.
11531193
"""
@@ -1158,6 +1198,7 @@ def ui(
11581198
width=width,
11591199
height=height,
11601200
fill=fill,
1201+
icon_assistant=icon_assistant,
11611202
**kwargs,
11621203
)
11631204

@@ -1171,6 +1212,7 @@ def chat_ui(
11711212
width: CssUnit = "min(680px, 100%)",
11721213
height: CssUnit = "auto",
11731214
fill: bool = True,
1215+
icon_assistant: HTML | Tag | TagList | None = None,
11741216
**kwargs: TagAttrValue,
11751217
) -> Tag:
11761218
"""
@@ -1199,6 +1241,10 @@ def chat_ui(
11991241
The height of the chat container.
12001242
fill
12011243
Whether the chat should vertically take available space inside a fillable container.
1244+
icon_assistant
1245+
The icon to use for the assistant chat messages. Can be a HTML or a tag in
1246+
the form of :class:`~htmltools.HTML` or :class:`~htmltools.Tag`. If `None`,
1247+
a default robot icon is used.
12021248
kwargs
12031249
Additional attributes for the chat container element.
12041250
"""
@@ -1226,6 +1272,10 @@ def chat_ui(
12261272

12271273
message_tags.append(Tag(tag_name, content=msg["content"]))
12281274

1275+
html_deps = None
1276+
if isinstance(icon_assistant, (Tag, TagList)):
1277+
html_deps = icon_assistant.get_dependencies()
1278+
12291279
res = Tag(
12301280
"shiny-chat-container",
12311281
Tag("shiny-chat-messages", *message_tags),
@@ -1235,6 +1285,7 @@ def chat_ui(
12351285
placeholder=placeholder,
12361286
),
12371287
chat_deps(),
1288+
html_deps,
12381289
{
12391290
"style": css(
12401291
width=as_css_unit(width),
@@ -1244,6 +1295,7 @@ def chat_ui(
12441295
id=id,
12451296
placeholder=placeholder,
12461297
fill=fill,
1298+
icon_assistant=str(icon_assistant) if icon_assistant is not None else None,
12471299
**kwargs,
12481300
)
12491301

shiny/ui/_chat_types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from htmltools import HTML
66

7+
from .._typing_extensions import NotRequired
8+
79
Role = Literal["assistant", "user", "system"]
810

911

@@ -27,3 +29,4 @@ class TransformedMessage(TypedDict):
2729
class ClientMessage(ChatMessage):
2830
content_type: Literal["markdown", "html"]
2931
chunk_type: Literal["message_start", "message_end"] | None
32+
icon: NotRequired[str]

shiny/www/py-shiny/chat/chat.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)