Skip to content

Commit d2e8991

Browse files
authored
Merge branch 'main' into docs/setup-and-docs
2 parents e967135 + ee1a47e commit d2e8991

File tree

20 files changed

+6599
-51
lines changed

20 files changed

+6599
-51
lines changed

.github/workflows/pytest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ jobs:
154154

155155
playwright-examples:
156156
if: github.event_name != 'release'
157-
runs-on: ubuntu-20.04
157+
runs-on: ubuntu-latest
158158
strategy:
159159
matrix:
160160
python-version: ["3.12", "3.11", "3.10", "3.9"]

.github/workflows/verify-js-built.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
run: |
4040
if [[ `git status --porcelain` ]]; then
4141
git diff
42-
echo "Uncommitted changes found. Please commit any changes that result from 'npm run build'."
42+
echo "Uncommitted changes found. Please commit any changes that result from 'npm ci && npm run build'."
4343
exit 1
4444
else
4545
echo "No uncommitted changes found."

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: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ shiny-chat-container {
33
--shiny-chat-user-message-bg: RGBA(var(--bs-primary-rgb, 0, 123, 194), 0.06);
44
--_chat-container-padding: 0.25rem;
55

6-
display: flex;
7-
flex-direction: column;
6+
display: grid;
7+
grid-template-columns: 1fr;
8+
grid-template-rows: 1fr auto;
89
margin: 0 auto;
9-
gap: 1rem;
10-
overflow: auto;
10+
gap: 0;
1111
padding: var(--_chat-container-padding);
1212
padding-bottom: 0; // Bottom padding is on input element
1313

@@ -54,6 +54,13 @@ shiny-chat-messages {
5454
display: flex;
5555
flex-direction: column;
5656
gap: 2rem;
57+
overflow: auto;
58+
margin-bottom: 1rem;
59+
60+
// Make space for the scroll bar
61+
--_scroll-margin: 1rem;
62+
padding-right: var(--_scroll-margin);
63+
margin-right: calc(-1 * var(--_scroll-margin));
5764
}
5865

5966
shiny-chat-message {
@@ -66,12 +73,30 @@ shiny-chat-message {
6673
.message-icon {
6774
border-radius: 50%;
6875
border: var(--shiny-chat-border);
76+
height: 2rem;
77+
width: 2rem;
78+
display: grid;
79+
place-items: center;
80+
overflow: clip;
81+
6982
> * {
70-
margin: 0.5rem;
71-
height: 20px;
72-
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%;
7397
}
7498
}
99+
75100
/* Vertically center the 2nd column (message content) */
76101
shiny-markdown-stream {
77102
align-self: center;
@@ -96,13 +121,12 @@ shiny-chat-message {
96121
}
97122

98123
shiny-chat-input {
99-
--_input-padding-top: 1rem;
124+
--_input-padding-top: 0;
100125
--_input-padding-bottom: var(--_chat-container-padding, 0.25rem);
101126

102-
margin-top: auto;
127+
margin-top: calc(-1 * var(--_input-padding-top));
103128
position: sticky;
104-
bottom: 0;
105-
background: linear-gradient(to bottom, transparent, var(--bs-body-bg, white) calc(var(--_input-padding-top) - var(--_input-padding-bottom)));
129+
bottom: calc(-1 * var(--_input-padding-bottom));
106130
padding-block: var(--_input-padding-top) var(--_input-padding-bottom);
107131

108132
textarea {

js/chat/chat.ts

Lines changed: 46 additions & 4 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>
@@ -72,8 +75,8 @@ class ChatMessage extends LightElement {
7275
content-type=${this.content_type}
7376
?streaming=${this.streaming}
7477
auto-scroll
75-
.onContentChange=${this.#onContentChange}
76-
.onStreamEnd=${this.#makeSuggestionsAccessible}
78+
.onContentChange=${this.#onContentChange.bind(this)}
79+
.onStreamEnd=${this.#makeSuggestionsAccessible.bind(this)}
7780
></shiny-markdown-stream>
7881
`;
7982
}
@@ -262,6 +265,8 @@ class ChatInput extends LightElement {
262265
}
263266

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

266271
private get input(): ChatInput {
267272
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
@@ -280,6 +285,35 @@ class ChatContainer extends LightElement {
280285
return html``;
281286
}
282287

288+
connectedCallback(): void {
289+
super.connectedCallback();
290+
291+
// We use a sentinel element that we place just above the shiny-chat-input. When it
292+
// moves off-screen we know that the text area input is now floating, add shadow.
293+
let sentinel = this.querySelector<HTMLElement>("div");
294+
if (!sentinel) {
295+
sentinel = createElement("div", {
296+
style: "width: 100%; height: 0;",
297+
}) as HTMLElement;
298+
this.input.insertAdjacentElement("afterend", sentinel);
299+
}
300+
301+
this.inputSentinelObserver = new IntersectionObserver(
302+
(entries) => {
303+
const inputTextarea = this.input.querySelector("textarea");
304+
if (!inputTextarea) return;
305+
const addShadow = entries[0]?.intersectionRatio === 0;
306+
inputTextarea.classList.toggle("shadow", addShadow);
307+
},
308+
{
309+
threshold: [0, 1],
310+
rootMargin: "0px",
311+
}
312+
);
313+
314+
this.inputSentinelObserver.observe(sentinel);
315+
}
316+
283317
firstUpdated(): void {
284318
// Don't attach event listeners until child elements are rendered
285319
if (!this.messages) return;
@@ -306,6 +340,9 @@ class ChatContainer extends LightElement {
306340
disconnectedCallback(): void {
307341
super.disconnectedCallback();
308342

343+
this.inputSentinelObserver?.disconnect();
344+
this.inputSentinelObserver = undefined;
345+
309346
this.removeEventListener("shiny-chat-input-sent", this.#onInputSent);
310347
this.removeEventListener("shiny-chat-append-message", this.#onAppend);
311348
this.removeEventListener(
@@ -348,6 +385,11 @@ class ChatContainer extends LightElement {
348385

349386
const TAG_NAME =
350387
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;
388+
389+
if (this.iconAssistant) {
390+
message.icon = message.icon || this.iconAssistant;
391+
}
392+
351393
const msg = createElement(TAG_NAME, message);
352394
this.messages.appendChild(msg);
353395

js/markdown-stream/markdown-stream.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,26 @@ pre:has(.code-copy-button) {
4949
background-color: var(--bs-success, #198754);
5050
}
5151
}
52+
53+
@keyframes markdown-stream-dot-pulse {
54+
0% {
55+
transform: scale(1);
56+
opacity: 1;
57+
}
58+
50% {
59+
transform: scale(0.4);
60+
opacity: 0.4;
61+
}
62+
100% {
63+
transform: scale(1);
64+
opacity: 1;
65+
}
66+
}
67+
68+
.markdown-stream-dot {
69+
// The stream dot is appended with each streaming chunk update, so the pulse animation
70+
// only shows up when streaming pauses but isn't complete.
71+
animation: markdown-stream-dot-pulse 2s infinite cubic-bezier(0.18, 0.89, 0.32, 1.28);
72+
display: inline-block;
73+
transform-origin: center;
74+
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ ignore = ["E302", "E501", "F403", "F405", "W503", "E203", "E701", "E704"]
158158

159159
[tool.isort]
160160
profile = "black"
161-
skip = ["__init__.py", "typings/", "_dev/", ".venv", "venv", ".tox", "build"]
161+
skip = ["__init__.py", "typings/", "_dev/", ".venv", "venv", ".tox", "build", "_version.py"]
162162

163163
[tool.mypy]
164164
# The goal of our usage of mypy is to make to sure mypy can run, not that it catches any errors (we use pyright to find our errors).

0 commit comments

Comments
 (0)