Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
76abfed
feat: Configurable chat icon
gadenbuie Feb 12, 2025
ebefd4f
chore: npm run build
gadenbuie Feb 12, 2025
da305e9
refactor: Undo modularization of test app
gadenbuie Feb 12, 2025
c2c0c27
chore: rename `userIcon`
gadenbuie Feb 12, 2025
11bc384
chore: remove icon template private method
gadenbuie Feb 12, 2025
0b87414
chore: Add artificial delay to message response
gadenbuie Feb 12, 2025
33f94fb
chore: Image sizing edge cases in css
gadenbuie Feb 12, 2025
d6b43a1
chore: small refactor for readability
gadenbuie Feb 12, 2025
45e662e
tests: use PNG icon instead of svg for test
gadenbuie Feb 12, 2025
2b2f105
Merge branch 'main' into feat/chat-icon
cpsievert Feb 14, 2025
fbce8d2
Add the ability to change the icon when appending a message; rely les…
cpsievert Feb 14, 2025
2f14281
Merge branch 'main' into feat/chat-icon
cpsievert Feb 14, 2025
048a36b
refactor: Use shadow root and slots in ChatContainer and ChatMessage
gadenbuie Feb 14, 2025
da8803c
fix: Create `<shiny-content-stream>` in a content slot in `<shiny-cha…
gadenbuie Feb 14, 2025
277f681
tests(chat-icon): Fix tests
gadenbuie Feb 14, 2025
4158e7a
fix: don't need to clone the node
gadenbuie Feb 14, 2025
12d6de3
chore: rename `icon` in `append_message()`
gadenbuie Feb 14, 2025
b2d5d43
tests: Add tests for dynamic chat icon
gadenbuie Feb 14, 2025
ced4c5c
Merge 'origin/main' into branch feat/chat-icon
gadenbuie Feb 14, 2025
68bac09
chore: make check-fix
gadenbuie Feb 14, 2025
658a562
tests: Explicitly test returning to no icon
gadenbuie Feb 14, 2025
0eb0d36
chore: fix types
gadenbuie Feb 14, 2025
0d64647
refactor: Return to icon attribute method
gadenbuie Feb 20, 2025
4907d7f
Merge branch 'main' into feat/chat-icon
gadenbuie Feb 20, 2025
f252c2c
feat: Make images (i.e. avatars) full-bleed
gadenbuie Feb 20, 2025
ee51869
docs: remove stray space
gadenbuie Feb 20, 2025
a24fffc
chore: Support TagList for `icon_assistant`, too
gadenbuie Feb 20, 2025
23742f4
Merge 'origin/main' into branch feat/chat-icon
gadenbuie Feb 24, 2025
cebb4ef
Merge 'origin/main' into branch feat/chat-icon
gadenbuie Feb 24, 2025
c1445b2
docs: Add changelog item
gadenbuie Feb 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions js/chat/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,30 @@ shiny-chat-message {
.message-icon {
border-radius: 50%;
border: var(--shiny-chat-border);
height: 2rem;
width: 2rem;
display: grid;
place-items: center;
overflow: clip;

> * {
margin: 0.5rem;
height: 20px;
width: 20px;
// images and avatars are full-bleed
height: 100%;
width: 100%;
margin: 0 !important;
object-fit: contain;
}

> svg,
> .icon,
> .fa,
> .bi {
// icons and svgs need some padding within the circle
max-height: 66%;
max-width: 66%;
}
}

/* Vertically center the 2nd column (message content) */
shiny-markdown-stream {
align-self: center;
Expand Down
13 changes: 11 additions & 2 deletions js/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Message = {
role: "user" | "assistant";
chunk_type: "message_start" | "message_end" | null;
content_type: ContentType;
icon?: string;
operation: "append" | null;
};
type ShinyChatMessage = {
Expand Down Expand Up @@ -60,10 +61,12 @@ class ChatMessage extends LightElement {
@property() content = "...";
@property() content_type: ContentType = "markdown";
@property({ type: Boolean, reflect: true }) streaming = false;
@property() icon = "";

render() {
const noContent = this.content.trim().length === 0;
const icon = noContent ? ICONS.dots_fade : ICONS.robot;
// Show dots until we have content
const isEmpty = this.content.trim().length === 0;
const icon = isEmpty ? ICONS.dots_fade : this.icon || ICONS.robot;

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

class ChatContainer extends LightElement {
@property({ attribute: "icon-assistant" }) iconAssistant = "";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel strongly, but have been using snake_case for other attributes

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was a little confused about this actually because it's icon_assistant in the Tag() call. Are you explicitly forcing snake case for content_type somewhere?

I'd say my preference for kebab case attributes trends toward strong (I didn't care enough to figure out why it's content_type and not content-type, but if I definitely wanted to change it when I saw it).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the question and the proposal here. Btw, since it's probably important context, shiny this behavior when writing tags:

>>> from shiny import ui
>>> ui.div(foo_bar = "baz")
<div foo-bar="baz"></div>
>>> ui.div(fooBar = "baz")
<div fooBar="baz"></div>

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is why I was surprised you have underscores in the attributes. So I used Tag(..., icon_assistant=str(icon_assistant)) in Python, which creates icon-assistant="...html...".

But you're saying you used snake_case for attributes like content_type, which means you had to do something special on the Python side, right?

As far as proposals, I'd prefer we pick one of these choices in order from first to last preference:

  1. kebab-case for attributes
  2. camelCase for attributes if we want to signal that they're reflected attributes
  3. snake_case for attributes if we want to signal that they came from Python

(I could be convinced into 2 or 3 if we have some guidelines for consistency, otherwise I'd much rather all attributes in kebab-case.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still a bit confused, can we be a bit more specific? AFAIK, I'm using Tag(content_type="") in Python which serializes to <tag content-type=""> in HTML. In the TS, the property name is content_type, but the attribute name is content-type. Are you proposing kebab-case everywhere (even for the TS property names)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the chat messages you pass JSON data (which has content_type) directly from the event to create the message element, so the <shiny-chat-message> (and similar) elements have content_type attributes, although when creating the <shiny-markdown-stream> you then go from content_type to content-type.

Anyway, I've always personally preferred:

  1. Snake case in R/Python, i.e. Tag("shiny-chat-message", content_type="html")
  2. Kebab-case in HTML, i.e. <shiny-chat-message content-type="html">
  3. camelCase in TS/JS, i.e. this.contentType.

But the event-data-direct-to-custom-element approach is easy and it would take a little more work to serialize those elements to JSON with kebab-case names.

Copy link
Collaborator

@cpsievert cpsievert Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, I overlooked the message handling aspect 🤦 .

Would it make sense to you if createElement() inside of js/utils/_utils.ts replaced _ with - (similar to what htmltools in Python does)?

Anyways, I don't think I have any strong opinions here and align with your preferences, so feel free to change as you see fit


private get input(): ChatInput {
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
Expand Down Expand Up @@ -348,6 +352,11 @@ class ChatContainer extends LightElement {

const TAG_NAME =
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;

if (this.iconAssistant) {
message.icon = message.icon || this.iconAssistant;
}

const msg = createElement(TAG_NAME, message);
this.messages.appendChild(msg);

Expand Down
78 changes: 65 additions & 13 deletions shiny/ui/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,12 @@ def messages(

return tuple(res)

async def append_message(self, message: Any) -> None:
async def append_message(
self,
message: Any,
*,
icon: HTML | Tag | None = None,
):
"""
Append a message to the chat.

Expand All @@ -506,10 +511,14 @@ async def append_message(self, message: Any) -> None:
Content strings are interpreted as markdown and rendered to HTML on the
client. Content may also include specially formatted **input suggestion**
links (see note below).
icon
An optional icon to display next to the message, currently only used for
assistant messages . The icon can be any HTML element (e.g., an
:func:`~shiny.ui.img` tag) or a string of HTML.

Note
----
``{.callout-note title="Input suggestions"}
:::{.callout-note title="Input suggestions"}
Input suggestions are special links that send text to the user input box when
clicked (or accessed via keyboard). They can be created in the following ways:

Expand All @@ -528,17 +537,22 @@ async def append_message(self, message: Any) -> None:

Note that a user may also opt-out of submitting a suggestion by holding the
`Alt/Option` key while clicking the suggestion link.
```
:::

```{.callout-note title="Streamed messages"}
:::{.callout-note title="Streamed messages"}
Use `.append_message_stream()` instead of this method when `stream=True` (or
similar) is specified in model's completion method.
```
:::
"""
await self._append_message(message)
await self._append_message(message, icon=icon)

async def _append_message(
self, message: Any, *, chunk: ChunkOption = False, stream_id: str | None = None
self,
message: Any,
*,
chunk: ChunkOption = False,
stream_id: str | None = None,
icon: HTML | Tag | None = None,
) -> None:
# If currently we're in a stream, handle other messages (outside the stream) later
if not self._can_append_message(stream_id):
Expand Down Expand Up @@ -568,9 +582,18 @@ async def _append_message(
if msg is None:
return
self._store_message(msg, chunk=chunk)
await self._send_append_message(msg, chunk=chunk)
await self._send_append_message(
msg,
chunk=chunk,
icon=icon,
)

async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any]):
async def append_message_stream(
self,
message: Iterable[Any] | AsyncIterable[Any],
*,
icon: HTML | Tag | None = None,
):
"""
Append a message as a stream of message chunks.

Expand All @@ -583,6 +606,10 @@ async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any
OpenAI, Anthropic, Ollama, and others. Content strings are interpreted as
markdown and rendered to HTML on the client. Content may also include
specially formatted **input suggestion** links (see note below).
icon
An optional icon to display next to the message, currently only used for
assistant messages . The icon can be any HTML element (e.g., an
:func:`~shiny.ui.img` tag) or a string of HTML.

Note
----
Expand Down Expand Up @@ -625,7 +652,7 @@ async def append_message_stream(self, message: Iterable[Any] | AsyncIterable[Any
# Run the stream in the background to get non-blocking behavior
@reactive.extended_task
async def _stream_task():
return await self._append_message_stream(message)
return await self._append_message_stream(message, icon=icon)

_stream_task()

Expand Down Expand Up @@ -669,11 +696,15 @@ def get_latest_stream_result(self) -> str | None:
else:
return stream.result()

async def _append_message_stream(self, message: AsyncIterable[Any]):
async def _append_message_stream(
self,
message: AsyncIterable[Any],
icon: HTML | Tag | None = None,
):
id = _utils.private_random_id()

empty = ChatMessage(content="", role="assistant")
await self._append_message(empty, chunk="start", stream_id=id)
await self._append_message(empty, chunk="start", stream_id=id, icon=icon)

try:
async for msg in message:
Expand Down Expand Up @@ -702,6 +733,7 @@ async def _send_append_message(
self,
message: TransformedMessage,
chunk: ChunkOption = False,
icon: HTML | Tag | None = None,
):
if message["role"] == "system":
# System messages are not displayed in the UI
Expand All @@ -721,13 +753,17 @@ async def _send_append_message(
content = message["content_client"]
content_type = "html" if isinstance(content, HTML) else "markdown"

# TODO: pass along dependencies for both content and icon (if any)
msg = ClientMessage(
content=str(content),
role=message["role"],
content_type=content_type,
chunk_type=chunk_type,
)

if icon is not None:
msg["icon"] = str(icon)

# print(msg)

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

@add_example(ex_dir="../templates/chat/starters/hello")
class ChatExpress(Chat):

def ui(
self,
*,
Expand All @@ -1127,6 +1162,7 @@ def ui(
width: CssUnit = "min(680px, 100%)",
height: CssUnit = "auto",
fill: bool = True,
icon_assistant: HTML | Tag | None = None,
**kwargs: TagAttrValue,
) -> Tag:
"""
Expand All @@ -1148,6 +1184,10 @@ def ui(
fill
Whether the chat should vertically take available space inside a fillable
container.
icon_assistant
The icon to use for the assistant chat messages. Can be a HTML or a tag in
the form of :class:`~htmltools.HTML` or :class:`~htmltools.Tag`. If `None`,
a default robot icon is used.
kwargs
Additional attributes for the chat container element.
"""
Expand All @@ -1158,6 +1198,7 @@ def ui(
width=width,
height=height,
fill=fill,
icon_assistant=icon_assistant,
**kwargs,
)

Expand All @@ -1171,6 +1212,7 @@ def chat_ui(
width: CssUnit = "min(680px, 100%)",
height: CssUnit = "auto",
fill: bool = True,
icon_assistant: HTML | Tag | None = None,
**kwargs: TagAttrValue,
) -> Tag:
"""
Expand Down Expand Up @@ -1199,6 +1241,10 @@ def chat_ui(
The height of the chat container.
fill
Whether the chat should vertically take available space inside a fillable container.
icon_assistant
The icon to use for the assistant chat messages. Can be a HTML or a tag in
the form of :class:`~htmltools.HTML` or :class:`~htmltools.Tag`. If `None`,
a default robot icon is used.
kwargs
Additional attributes for the chat container element.
"""
Expand Down Expand Up @@ -1226,6 +1272,10 @@ def chat_ui(

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

html_deps = None
if isinstance(icon_assistant, Tag):
html_deps = icon_assistant.get_dependencies()

res = Tag(
"shiny-chat-container",
Tag("shiny-chat-messages", *message_tags),
Expand All @@ -1235,6 +1285,7 @@ def chat_ui(
placeholder=placeholder,
),
chat_deps(),
html_deps,
{
"style": css(
width=as_css_unit(width),
Expand All @@ -1244,6 +1295,7 @@ def chat_ui(
id=id,
placeholder=placeholder,
fill=fill,
icon_assistant=str(icon_assistant) if icon_assistant is not None else None,
**kwargs,
)

Expand Down
3 changes: 3 additions & 0 deletions shiny/ui/_chat_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from htmltools import HTML

from .._typing_extensions import NotRequired

Role = Literal["assistant", "user", "system"]


Expand All @@ -27,3 +29,4 @@ class TransformedMessage(TypedDict):
class ClientMessage(ChatMessage):
content_type: Literal["markdown", "html"]
chunk_type: Literal["message_start", "message_end"] | None
icon: NotRequired[str]
2 changes: 1 addition & 1 deletion shiny/www/py-shiny/chat/chat.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading