Skip to content

Commit fbce8d2

Browse files
committed
Add the ability to change the icon when appending a message; rely less on DOM state for icon management
1 parent 2b2f105 commit fbce8d2

File tree

8 files changed

+82
-60
lines changed

8 files changed

+82
-60
lines changed

js/chat/chat.scss

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@ shiny-chat-container {
1313
margin-bottom: 0;
1414
}
1515

16-
[data-icon="assistant"] {
17-
display: none;
18-
}
19-
2016
.suggestion,
2117
[data-suggestion] {
2218
cursor: pointer;

js/chat/chat.ts

Lines changed: 13 additions & 17 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,20 +61,19 @@ 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 defaultIcon = noContent ? ICONS.dots_fade : ICONS.robot;
67+
let msg_icon = ICONS.dots_fade;
6768

68-
// Check if there's an existing message-icon element
69-
const userIcon = this.querySelector(".message-icon");
70-
const icon =
71-
userIcon && !noContent
72-
? userIcon
73-
: html`<div class="message-icon">${unsafeHTML(defaultIcon)}</div>`;
69+
// Show dots until we have content
70+
const hasContent = this.content.trim().length !== 0;
71+
if (hasContent) {
72+
msg_icon = this.icon || ICONS.robot;
73+
}
7474

7575
return html`
76-
${icon}
76+
<div class="message-icon">${unsafeHTML(msg_icon)}</div>
7777
<shiny-markdown-stream
7878
content=${this.content}
7979
content-type=${this.content_type}
@@ -269,6 +269,7 @@ class ChatInput extends LightElement {
269269
}
270270

271271
class ChatContainer extends LightElement {
272+
@property() icon = "";
272273

273274
private get input(): ChatInput {
274275
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
@@ -355,17 +356,12 @@ class ChatContainer extends LightElement {
355356

356357
const TAG_NAME =
357358
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;
358-
const msg = createElement(TAG_NAME, message);
359359

360-
if (message.role !== "user") {
361-
const iconTemplate = this.querySelector('div[data-icon="assistant"]');
362-
if (iconTemplate) {
363-
const icon = iconTemplate.cloneNode(true) as HTMLDivElement;
364-
icon.className = "message-icon";
365-
msg.appendChild(icon);
366-
}
360+
if (this.icon) {
361+
message.icon = message.icon || this.icon;
367362
}
368363

364+
const msg = createElement(TAG_NAME, message);
369365
this.messages.appendChild(msg);
370366

371367
if (finalize) {

shiny/ui/_chat.py

Lines changed: 52 additions & 25 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, div
20+
from htmltools import HTML, Tag, TagAttrValue, css
2121

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

490490
return tuple(res)
491491

492-
async def append_message(self, message: Any) -> None:
492+
async def append_message(
493+
self,
494+
message: Any,
495+
*,
496+
assistant_icon: HTML | Tag | None = None,
497+
):
493498
"""
494499
Append a message to the chat.
495500
@@ -502,6 +507,9 @@ async def append_message(self, message: Any) -> None:
502507
Content strings are interpreted as markdown and rendered to HTML on the
503508
client. Content may also include specially formatted **input suggestion**
504509
links (see note below).
510+
assistant_icon
511+
An optional icon to display next to the assistant message. The icon can be
512+
any HTML element (e.g., an :func:`~shiny.ui.img` tag) or a string of HTML.
505513
506514
Note
507515
----
@@ -531,10 +539,15 @@ async def append_message(self, message: Any) -> None:
531539
similar) is specified in model's completion method.
532540
```
533541
"""
534-
await self._append_message(message)
542+
await self._append_message(message, icon=assistant_icon)
535543

536544
async def _append_message(
537-
self, message: Any, *, chunk: ChunkOption = False, stream_id: str | None = None
545+
self,
546+
message: Any,
547+
*,
548+
chunk: ChunkOption = False,
549+
stream_id: str | None = None,
550+
icon: HTML | Tag | None = None,
538551
) -> None:
539552
# If currently we're in a stream, handle other messages (outside the stream) later
540553
if not self._can_append_message(stream_id):
@@ -564,9 +577,18 @@ async def _append_message(
564577
if msg is None:
565578
return
566579
self._store_message(msg, chunk=chunk)
567-
await self._send_append_message(msg, chunk=chunk)
580+
await self._send_append_message(
581+
msg,
582+
chunk=chunk,
583+
icon=icon,
584+
)
568585

569-
async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any]):
586+
async def append_message_stream(
587+
self,
588+
message: Iterable[Any] | AsyncIterable[Any],
589+
*,
590+
assistant_icon: HTML | Tag | None = None,
591+
):
570592
"""
571593
Append a message as a stream of message chunks.
572594
@@ -579,6 +601,9 @@ async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any
579601
OpenAI, Anthropic, Ollama, and others. Content strings are interpreted as
580602
markdown and rendered to HTML on the client. Content may also include
581603
specially formatted **input suggestion** links (see note below).
604+
assistant_icon
605+
An optional icon to display next to the assistant message. The icon can be
606+
any HTML element (e.g., an :func:`~shiny.ui.img` tag) or a string of HTML.
582607
583608
Note
584609
----
@@ -614,7 +639,7 @@ async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any
614639
# Run the stream in the background to get non-blocking behavior
615640
@reactive.extended_task
616641
async def _stream_task():
617-
await self._append_message_stream(message)
642+
await self._append_message_stream(message, icon=assistant_icon)
618643

619644
_stream_task()
620645

@@ -627,11 +652,15 @@ async def _handle_error():
627652
await self._raise_exception(e)
628653
_handle_error.destroy() # type: ignore
629654

630-
async def _append_message_stream(self, message: AsyncIterable[Any]):
655+
async def _append_message_stream(
656+
self,
657+
message: AsyncIterable[Any],
658+
icon: HTML | Tag | None = None,
659+
):
631660
id = _utils.private_random_id()
632661

633662
empty = ChatMessage(content="", role="assistant")
634-
await self._append_message(empty, chunk="start", stream_id=id)
663+
await self._append_message(empty, chunk="start", stream_id=id, icon=icon)
635664

636665
try:
637666
async for msg in message:
@@ -659,6 +688,7 @@ async def _send_append_message(
659688
self,
660689
message: TransformedMessage,
661690
chunk: ChunkOption = False,
691+
icon: HTML | Tag | None = None,
662692
):
663693
if message["role"] == "system":
664694
# System messages are not displayed in the UI
@@ -678,13 +708,17 @@ async def _send_append_message(
678708
content = message["content_client"]
679709
content_type = "html" if isinstance(content, HTML) else "markdown"
680710

711+
# TODO: pass along dependencies for both content and icon (if any)
681712
msg = ClientMessage(
682713
content=str(content),
683714
role=message["role"],
684715
content_type=content_type,
685716
chunk_type=chunk_type,
686717
)
687718

719+
if icon is not None:
720+
msg["icon"] = str(icon)
721+
688722
# print(msg)
689723

690724
await self._send_custom_message(msg_type, msg)
@@ -1187,34 +1221,26 @@ def chat_ui(
11871221
raise ValueError("Each message must be a string or a dictionary.")
11881222

11891223
if msg["role"] == "user":
1190-
msg_tag = Tag("shiny-user-message", content=msg["content"])
1224+
tag_name = "shiny-user-message"
11911225
else:
1192-
msg_tag = Tag(
1193-
"shiny-chat-message",
1194-
(
1195-
None
1196-
if icon_assistant is None
1197-
else div(icon_assistant, class_="message-icon")
1198-
),
1199-
content=msg["content"],
1200-
)
1226+
tag_name = "shiny-chat-message"
12011227

1202-
message_tags.append(msg_tag)
1228+
message_tags.append(Tag(tag_name, content=msg["content"]))
1229+
1230+
html_deps = None
1231+
if isinstance(icon_assistant, Tag):
1232+
html_deps = icon_assistant.get_dependencies()
12031233

12041234
res = Tag(
12051235
"shiny-chat-container",
1206-
(
1207-
None
1208-
if icon_assistant is None
1209-
else div(HTML(icon_assistant), data_icon="assistant")
1210-
),
12111236
Tag("shiny-chat-messages", *message_tags),
12121237
Tag(
12131238
"shiny-chat-input",
12141239
id=f"{id}_user_input",
12151240
placeholder=placeholder,
12161241
),
12171242
chat_deps(),
1243+
html_deps,
12181244
{
12191245
"style": css(
12201246
width=as_css_unit(width),
@@ -1224,6 +1250,7 @@ def chat_ui(
12241250
id=id,
12251251
placeholder=placeholder,
12261252
fill=fill,
1253+
icon=str(icon_assistant) if icon_assistant else None,
12271254
**kwargs,
12281255
)
12291256

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)