Skip to content

Commit 144ab69

Browse files
jcheng5cpsievert
andauthored
Chat tweaks (#1607)
Co-authored-by: Carson <[email protected]>
1 parent 0f9ec9e commit 144ab69

File tree

10 files changed

+139
-67
lines changed

10 files changed

+139
-67
lines changed

CHANGELOG.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
### Other changes
1919

20+
* A few changes for `ui.Chat()`, including:
21+
* User input that contains markdown now renders the expected HTML. (#1607)
22+
* Busy indication is now visible/apparent during the entire lifecycle of response generation. (#1607)
23+
2024
### Bug fixes
2125

22-
* A handful of fixes for `ui.Chat()`, including:
26+
* A few fixes for `ui.Chat()`, including:
2327
* A fix for use inside Shiny modules. (#1582)
2428
* `.messages(format="google")` now returns the correct role. (#1622)
2529
* `ui.Chat(messages)` are no longer dropped when dynamically rendered. (#1593)

js/.nvmrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v16
1+
v18

js/chat/chat.scss

+25-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ shiny-chat-container {
99
margin: 0 auto;
1010
gap: 1rem;
1111
overflow: auto;
12+
padding: 0.25rem;
1213

1314
p:last-child {
1415
margin-bottom: 0;
@@ -39,6 +40,17 @@ shiny-chat-container {
3940
.message-content {
4041
align-self: center;
4142
}
43+
.message-streaming-icon {
44+
display: none;
45+
opacity: 0;
46+
}
47+
&[streaming] .message-streaming-icon {
48+
display: block;
49+
animation-delay: 2s;
50+
animation-name: fade-in;
51+
animation-duration: 10ms;
52+
animation-fill-mode: forwards;
53+
}
4254
}
4355

4456
/* Align the user message to the right */
@@ -47,6 +59,7 @@ shiny-chat-container {
4759
padding: 0.75rem 1rem;
4860
border-radius: 10px;
4961
background-color: var(--shiny-chat-user-message-bg);
62+
max-width: 100%;
5063
}
5164
}
5265

@@ -55,7 +68,6 @@ shiny-chat-container {
5568
position: sticky;
5669
background-color: var(--bs-body-bg, white);
5770
bottom: 0;
58-
padding: 0.25rem;
5971
textarea {
6072
--bs-border-radius: 26px;
6173
resize: none;
@@ -67,8 +79,8 @@ shiny-chat-container {
6779
}
6880
button {
6981
position: absolute;
70-
bottom: 10px;
71-
right: 11px;
82+
bottom: 7px;
83+
right: 8px;
7284
background-color: transparent;
7385
color: var(--bs-primary, #007bc2);
7486
transition: color 0.25s ease-in-out;
@@ -142,3 +154,13 @@ pre:has(.code-copy-button) {
142154
background-color: var(--bs-success, #198754);
143155
}
144156
}
157+
158+
/* Keyframes for the fading spinner */
159+
@keyframes fade-in {
160+
0% {
161+
opacity: 0;
162+
}
163+
100% {
164+
opacity: 1;
165+
}
166+
}

js/chat/chat.ts

+68-28
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { property } from "lit/decorators.js";
55
import ClipboardJS from "clipboard";
66
import { sanitize } from "dompurify";
77
import hljs from "highlight.js/lib/common";
8-
import { parse } from "marked";
8+
import { Renderer, parse } from "marked";
99

1010
import { createElement } from "./_utils";
1111

@@ -51,6 +51,17 @@ const CHAT_MESSAGES_TAG = "shiny-chat-messages";
5151
const CHAT_INPUT_TAG = "shiny-chat-input";
5252
const CHAT_CONTAINER_TAG = "shiny-chat-container";
5353

54+
const ICONS = {
55+
robot:
56+
'<svg fill="currentColor" class="bi bi-robot" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135"/><path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5"/></svg>',
57+
// https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg
58+
dots_fade:
59+
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>',
60+
// https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/bouncing-ball.svg
61+
ball_bounce:
62+
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_rXNP{animation:spinner_YeBj .8s infinite; opacity:.8}@keyframes spinner_YeBj{0%{animation-timing-function:cubic-bezier(0.33,0,.66,.33);cy:5px}46.875%{cy:20px;rx:4px;ry:4px}50%{animation-timing-function:cubic-bezier(0.33,.66,.66,1);cy:20.5px;rx:4.8px;ry:3px}53.125%{rx:4px;ry:4px}100%{cy:5px}}</style><ellipse class="spinner_rXNP" cx="12" cy="5" rx="4" ry="4"/></svg>',
63+
};
64+
5465
const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
5566
el.dispatchEvent(
5667
new CustomEvent("shiny-chat-request-scroll", {
@@ -61,6 +72,40 @@ const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
6172
);
6273
};
6374

75+
// For rendering chat output, we use typical Markdown behavior of passing through raw
76+
// HTML (albeit sanitizing afterwards).
77+
//
78+
// For echoing chat input, we escape HTML. This is not for security reasons but just
79+
// because it's confusing if the user is using tag-like syntax to demarcate parts of
80+
// their prompt for other reasons (like <User>/<Assistant> for providing examples to the
81+
// chat model), and those tags simply vanish.
82+
const rendererEscapeHTML = new Renderer();
83+
rendererEscapeHTML.html = (html: string) =>
84+
html
85+
.replaceAll("&", "&amp;")
86+
.replaceAll("<", "&lt;")
87+
.replaceAll(">", "&gt;")
88+
.replaceAll('"', "&quot;")
89+
.replaceAll("'", "&#039;");
90+
const markedEscapeOpts = { renderer: rendererEscapeHTML };
91+
92+
function contentToHTML(
93+
content: string,
94+
content_type: ContentType | "semi-markdown"
95+
) {
96+
if (content_type === "markdown") {
97+
return unsafeHTML(sanitize(parse(content) as string));
98+
} else if (content_type === "semi-markdown") {
99+
return unsafeHTML(sanitize(parse(content, markedEscapeOpts) as string));
100+
} else if (content_type === "html") {
101+
return unsafeHTML(sanitize(content));
102+
} else if (content_type === "text") {
103+
return content;
104+
} else {
105+
throw new Error(`Unknown content type: ${content_type}`);
106+
}
107+
}
108+
64109
// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
65110
class LightElement extends LitElement {
66111
createRenderRoot() {
@@ -69,29 +114,20 @@ class LightElement extends LitElement {
69114
}
70115

71116
class ChatMessage extends LightElement {
72-
@property() content = "...";
117+
@property() content = "";
73118
@property() content_type: ContentType = "markdown";
74-
@property({ type: Boolean, reflect: true }) is_streaming = false;
119+
@property({ type: Boolean, reflect: true }) streaming = false;
75120

76121
render(): ReturnType<LitElement["render"]> {
77-
let content;
78-
if (this.content_type === "markdown") {
79-
content = unsafeHTML(sanitize(parse(this.content) as string));
80-
} else if (this.content_type === "html") {
81-
content = unsafeHTML(sanitize(this.content));
82-
} else if (this.content_type === "text") {
83-
content = this.content;
84-
} else {
85-
throw new Error(`Unknown content type: ${this.content_type}`);
86-
}
122+
const content = contentToHTML(this.content, this.content_type);
87123

88-
// TODO: support custom icons
89-
const icon =
90-
'<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-robot" viewBox="0 0 16 16"><path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135"/><path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5"/></svg>';
124+
const noContent = this.content.trim().length === 0;
125+
const icon = noContent ? ICONS.dots_fade : ICONS.robot;
91126

92127
return html`
93128
<div class="message-icon">${unsafeHTML(icon)}</div>
94129
<div class="message-content">${content}</div>
130+
<div class="message-streaming-icon">${unsafeHTML(ICONS.ball_bounce)}</div>
95131
`;
96132
}
97133

@@ -100,7 +136,7 @@ class ChatMessage extends LightElement {
100136
this.#highlightAndCodeCopy();
101137
// It's important that the scroll request happens at this point in time, since
102138
// otherwise, the content may not be fully rendered yet
103-
requestScroll(this, this.is_streaming);
139+
requestScroll(this, this.streaming);
104140
}
105141
}
106142

@@ -136,7 +172,7 @@ class ChatUserMessage extends LightElement {
136172
@property() content = "...";
137173

138174
render(): ReturnType<LitElement["render"]> {
139-
return html`${this.content}`;
175+
return contentToHTML(this.content, "semi-markdown");
140176
}
141177
}
142178

@@ -251,6 +287,11 @@ class ChatContainer extends LightElement {
251287
return this.querySelector(CHAT_MESSAGES_TAG) as ChatMessages;
252288
}
253289

290+
private get lastMessage(): ChatMessage | null {
291+
const last = this.messages.lastElementChild;
292+
return last ? (last as ChatMessage) : null;
293+
}
294+
254295
private resizeObserver!: ResizeObserver;
255296

256297
render(): ReturnType<LitElement["render"]> {
@@ -341,9 +382,7 @@ class ChatContainer extends LightElement {
341382

342383
#addLoadingMessage(): void {
343384
const loading_message = {
344-
// https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg
345-
content:
346-
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>',
385+
content: "",
347386
role: "assistant",
348387
id: `${this.id}-loading-message`,
349388
};
@@ -364,21 +403,21 @@ class ChatContainer extends LightElement {
364403
#appendMessageChunk(message: Message): void {
365404
if (message.chunk_type === "message_start") {
366405
this.#appendMessage(message, false);
367-
return;
368406
}
369407

370-
const lastMessage = this.messages.lastElementChild as HTMLElement;
408+
const lastMessage = this.lastMessage;
371409
if (!lastMessage) throw new Error("No messages found in the chat output");
372410

373-
if (message.chunk_type === "message_end") {
374-
lastMessage.removeAttribute("is_streaming");
375-
lastMessage.setAttribute("content", message.content);
376-
this.#finalizeMessage();
411+
if (message.chunk_type === "message_start") {
412+
lastMessage.setAttribute("streaming", "");
377413
return;
378414
}
379415

380-
lastMessage.setAttribute("is_streaming", "");
381416
lastMessage.setAttribute("content", message.content);
417+
418+
if (message.chunk_type === "message_end") {
419+
this.#finalizeMessage();
420+
}
382421
}
383422

384423
#onClear(): void {
@@ -402,6 +441,7 @@ class ChatContainer extends LightElement {
402441

403442
#finalizeMessage(): void {
404443
this.input.disabled = false;
444+
this.lastMessage?.removeAttribute("streaming");
405445
}
406446

407447
#onRequestScroll(event: CustomEvent<requestScrollEvent>): void {

shiny/playwright/controller/_controls.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -6208,7 +6208,10 @@ def expect_latest_message(
62086208
timeout
62096209
The maximum time to wait for the expectation to pass. Defaults to `None`.
62106210
"""
6211-
playwright_expect(self.loc_latest_message).to_have_text(value, timeout=timeout)
6211+
# playwright_expect(self.loc_latest_message).to_have_text(value, timeout=timeout)
6212+
playwright_expect(self.loc_latest_message).to_have_text(
6213+
value, use_inner_text=True, timeout=timeout
6214+
)
62126215

62136216
def expect_messages(
62146217
self,
@@ -6226,7 +6229,9 @@ def expect_messages(
62266229
timeout
62276230
The maximum time to wait for the expectation to pass. Defaults to `None`.
62286231
"""
6229-
playwright_expect(self.loc_messages).to_have_text(value, timeout=timeout)
6232+
playwright_expect(self.loc_messages).to_have_text(
6233+
value, use_inner_text=True, timeout=timeout
6234+
)
62306235

62316236
def set_user_input(
62326237
self,

shiny/ui/_chat.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ async def _raise_exception(
344344
else:
345345
await self._remove_loading_message()
346346
sanitize = self.on_error == "sanitize"
347-
raise NotifyException(str(e), sanitize=sanitize)
347+
raise NotifyException(str(e), sanitize=sanitize) from e
348348

349349
@overload
350350
def messages(

0 commit comments

Comments
 (0)