Skip to content

Commit 57a3c31

Browse files
committed
feat(chat): Add status messages
1 parent ab2a338 commit 57a3c31

File tree

4 files changed

+108
-4
lines changed

4 files changed

+108
-4
lines changed

js/chat/chat.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ shiny-chat-message {
9595
}
9696
}
9797

98+
shiny-status-message {
99+
opacity: 0.8;
100+
text-align: center;
101+
font-size: 0.9em;
102+
}
103+
98104
shiny-chat-input {
99105
--_input-padding-top: 1rem;
100106
--_input-padding-bottom: var(--_chat-container-padding, 0.25rem);

js/chat/chat.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Message = {
1717
content_type: ContentType;
1818
operation: "append" | null;
1919
};
20+
2021
type ShinyChatMessage = {
2122
id: string;
2223
handler: string;
@@ -30,6 +31,12 @@ type UpdateUserInput = {
3031
focus?: false;
3132
};
3233

34+
type StatusMessage = {
35+
content: string;
36+
content_type: Exclude<ContentType, "markdown">;
37+
replaceable: "true" | "false" | "";
38+
};
39+
3340
// https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734
3441
declare global {
3542
interface GlobalEventHandlersEventMap {
@@ -39,11 +46,13 @@ declare global {
3946
"shiny-chat-clear-messages": CustomEvent;
4047
"shiny-chat-update-user-input": CustomEvent<UpdateUserInput>;
4148
"shiny-chat-remove-loading-message": CustomEvent;
49+
"shiny-chat-append-status-message": CustomEvent<StatusMessage>;
4250
}
4351
}
4452

4553
const CHAT_MESSAGE_TAG = "shiny-chat-message";
4654
const CHAT_USER_MESSAGE_TAG = "shiny-user-message";
55+
const CHAT_STATUS_MESSAGE_TAG = "shiny-status-message";
4756
const CHAT_MESSAGES_TAG = "shiny-chat-messages";
4857
const CHAT_INPUT_TAG = "shiny-chat-input";
4958
const CHAT_CONTAINER_TAG = "shiny-chat-container";
@@ -109,6 +118,32 @@ class ChatUserMessage extends LightElement {
109118
}
110119
}
111120

121+
class ChatStatusMessage extends LightElement {
122+
@property() content = "";
123+
@property() content_type: Exclude<ContentType, "markdown"> = "text";
124+
@property() type: "dynamic" | "static" = "static";
125+
126+
render() {
127+
const content =
128+
this.content_type === "html" ? unsafeHTML(this.content) : this.content;
129+
return html`${content}`;
130+
}
131+
132+
updated(changedProperties: Map<string, unknown>) {
133+
super.updated(changedProperties);
134+
if (
135+
changedProperties.has("content") ||
136+
changedProperties.has("content_type")
137+
) {
138+
this.#scrollIntoView();
139+
}
140+
}
141+
142+
#scrollIntoView() {
143+
this.scrollIntoView({ behavior: "smooth", block: "end" });
144+
}
145+
}
146+
112147
class ChatMessages extends LightElement {
113148
render() {
114149
return html``;
@@ -262,7 +297,6 @@ class ChatInput extends LightElement {
262297
}
263298

264299
class ChatContainer extends LightElement {
265-
266300
private get input(): ChatInput {
267301
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
268302
}
@@ -272,7 +306,7 @@ class ChatContainer extends LightElement {
272306
}
273307

274308
private get lastMessage(): ChatMessage | null {
275-
const last = this.messages.lastElementChild;
309+
const last = this.messages.querySelector("shiny-chat-message:last-child");
276310
return last ? (last as ChatMessage) : null;
277311
}
278312

@@ -290,6 +324,10 @@ class ChatContainer extends LightElement {
290324
"shiny-chat-append-message-chunk",
291325
this.#onAppendChunk
292326
);
327+
this.addEventListener(
328+
"shiny-chat-append-status-message",
329+
this.#onAppendStatus
330+
);
293331
this.addEventListener("shiny-chat-clear-messages", this.#onClear);
294332
this.addEventListener(
295333
"shiny-chat-update-user-input",
@@ -312,6 +350,10 @@ class ChatContainer extends LightElement {
312350
"shiny-chat-append-message-chunk",
313351
this.#onAppendChunk
314352
);
353+
this.removeEventListener(
354+
"shiny-chat-append-status-message",
355+
this.#onAppendStatus
356+
);
315357
this.removeEventListener("shiny-chat-clear-messages", this.#onClear);
316358
this.removeEventListener(
317359
"shiny-chat-update-user-input",
@@ -401,6 +443,20 @@ class ChatContainer extends LightElement {
401443
}
402444
}
403445

446+
#onAppendStatus(event: CustomEvent<StatusMessage>): void {
447+
if (this.messages.lastChild instanceof ChatStatusMessage) {
448+
if (this.messages.lastChild.type == "dynamic") {
449+
// Update previous status message if last message was a status item
450+
this.messages.lastChild.content = event.detail.content;
451+
this.messages.lastChild.content_type = event.detail.content_type;
452+
return;
453+
}
454+
}
455+
456+
const status = createElement(CHAT_STATUS_MESSAGE_TAG, event.detail);
457+
this.messages.appendChild(status);
458+
}
459+
404460
#onClear(): void {
405461
this.messages.innerHTML = "";
406462
}
@@ -481,6 +537,7 @@ class ChatContainer extends LightElement {
481537

482538
customElements.define(CHAT_MESSAGE_TAG, ChatMessage);
483539
customElements.define(CHAT_USER_MESSAGE_TAG, ChatUserMessage);
540+
customElements.define(CHAT_STATUS_MESSAGE_TAG, ChatStatusMessage);
484541
customElements.define(CHAT_MESSAGES_TAG, ChatMessages);
485542
customElements.define(CHAT_INPUT_TAG, ChatInput);
486543
customElements.define(CHAT_CONTAINER_TAG, ChatContainer);

shiny/ui/_chat.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@
3838
as_provider_message,
3939
)
4040
from ._chat_tokenizer import TokenEncoding, TokenizersEncoding, get_default_tokenizer
41-
from ._chat_types import ChatMessage, ClientMessage, TransformedMessage
41+
from ._chat_types import (
42+
ChatMessage,
43+
ChatStatusMessage,
44+
ClientMessage,
45+
TransformedMessage,
46+
)
4247
from ._html_deps_py_shiny import chat_deps
4348
from .fill import as_fill_item, as_fillable_container
4449

@@ -734,6 +739,34 @@ async def _send_append_message(
734739
# TODO: Joe said it's a good idea to yield here, but I'm not sure why?
735740
# await asyncio.sleep(0)
736741

742+
async def append_status_message(
743+
self,
744+
content: str | Tag | HTML,
745+
type: Literal["dynamic", "static"] = "dynamic"
746+
) -> None:
747+
"""
748+
Append a status message to the chat.
749+
750+
Adds or updates a status message in the chat messages area.
751+
752+
Parameters
753+
----------
754+
content
755+
The content of the message to append, as a string of plain text or as HTML
756+
in the form of :class:`~htmltools.Tag` or :class:`~htmltools.HTML`.
757+
type
758+
Whether this status message is `"dynamic"` and can be replaced by a
759+
subsequent status message before the next user or assistant turn, or if the
760+
message should not be updated by subsequent status messages (`"static"`).
761+
"""
762+
763+
msg: ChatStatusMessage = {
764+
"content": str(content),
765+
"content_type": "html" if isinstance(content, (Tag, HTML)) else "text",
766+
"type": type
767+
}
768+
await self._send_custom_message("shiny-chat-append-status-message", msg)
769+
737770
@overload
738771
def transform_user_input(
739772
self, fn: TransformUserInput | TransformUserInputAsync
@@ -1105,7 +1138,9 @@ def destroy(self):
11051138
async def _remove_loading_message(self):
11061139
await self._send_custom_message("shiny-chat-remove-loading-message", None)
11071140

1108-
async def _send_custom_message(self, handler: str, obj: ClientMessage | None):
1141+
async def _send_custom_message(
1142+
self, handler: str, obj: ClientMessage | ChatStatusMessage | None
1143+
):
11091144
await self._session.send_custom_message(
11101145
"shinyChatMessage",
11111146
{

shiny/ui/_chat_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ class ChatMessage(TypedDict):
1414
role: Role
1515

1616

17+
class ChatStatusMessage(TypedDict):
18+
content: str
19+
content_type: Literal["html", "text"]
20+
type: Literal["dynamic", "static"]
21+
22+
1723
# A message once transformed have been applied
1824
class TransformedMessage(TypedDict):
1925
content_client: str | HTML

0 commit comments

Comments
 (0)