Skip to content

Commit ac5ada7

Browse files
committed
Merge branch 'main' into chat-append-stream-return-task
2 parents 8057441 + 33bf25d commit ac5ada7

File tree

17 files changed

+672
-75
lines changed

17 files changed

+672
-75
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
* Added a new `ui.MarkdownStream()` component for performantly streaming in chunks of markdown/html strings into the UI. This component is primarily useful for text-based generative AI where responses are received incrementally. (#1782)
1313

14+
* The `ui.Chat()` component now supports input suggestion links. This feature is useful for providing users with clickable suggestions that can be used to quickly input text into the chat. This can be done in 2 different ways (see #1845 for more details):
15+
* By adding a `.suggestion` CSS class to an HTML element (e.g., `<span class="suggestion">A suggestion</span>`)
16+
* Add a `data-suggestion` attribute to an HTML element, and set the value to the input suggestion text (e.g., `<span data-suggestion="Suggestion value">Suggestion link</span>`)
17+
* To auto-submit the suggestion when clicked by the user, include the `.submit` class or the `data-suggestion-submit="true"` attribute on the HTML element. Alternatively, use Cmd/Ctrl + click to auto-submit any suggestion or Alt/Opt + click to apply any suggestion to the chat input without submitting.
18+
1419
* Added a new `.add_sass_layer_file()` method to `ui.Theme` that supports reading a Sass file with layer boundary comments, e.g. `/*-- scss:defaults --*/`. This format [is supported by Quarto](https://quarto.org/docs/output-formats/html-themes-more.html#bootstrap-bootswatch-layering) and makes it easier to store Sass rules and declarations that need to be woven into Shiny's Sass Bootstrap files. (#1790)
1520

1621
* The `ui.Chat()` component gains the following:
@@ -24,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2429

2530
* Available `input` ids can now be listed via `dir(input)`. This also works on the new `session.clientdata` object. (#1832)
2631

32+
* 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)
33+
2734
### Bug fixes
2835

2936
* `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)

js/chat/chat.scss

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,53 @@
11
shiny-chat-container {
22
--shiny-chat-border: var(--bs-border-width, 1px) solid var(--bs-border-color, #e9ecef);
33
--shiny-chat-user-message-bg: RGBA(var(--bs-primary-rgb, 0, 123, 194), 0.06);
4+
--_chat-container-padding: 0.25rem;
45

56
display: flex;
67
flex-direction: column;
78
margin: 0 auto;
89
gap: 1rem;
910
overflow: auto;
10-
padding: 0.25rem;
11+
padding: var(--_chat-container-padding);
12+
padding-bottom: 0; // Bottom padding is on input element
1113

1214
p:last-child {
1315
margin-bottom: 0;
1416
}
17+
18+
.suggestion,
19+
[data-suggestion] {
20+
cursor: pointer;
21+
color: var(--bs-link-color, #007bc2);
22+
23+
text-decoration-color: var(--bs-link-color, #007bc2);
24+
text-decoration-line: underline;
25+
text-decoration-style: dotted;
26+
text-decoration-thickness: 2px;
27+
text-underline-offset: 2px;
28+
text-underline-offset: 4px;
29+
text-decoration-thickness: 2px;
30+
31+
padding: 2px;
32+
33+
&:hover {
34+
text-decoration-style: solid;
35+
}
36+
37+
&::after {
38+
content: "\2726"; // diamond/star
39+
display: inline-block;
40+
margin-inline-start: 0.15em;
41+
}
42+
43+
&.submit,
44+
&[data-suggestion-submit=""],
45+
&[data-suggestion-submit="true"] {
46+
&::after {
47+
content: "\21B5"; // return key symbol
48+
}
49+
}
50+
}
1551
}
1652

1753
shiny-chat-messages {
@@ -60,10 +96,15 @@ shiny-chat-message {
6096
}
6197

6298
shiny-chat-input {
99+
--_input-padding-top: 1rem;
100+
--_input-padding-bottom: var(--_chat-container-padding, 0.25rem);
101+
63102
margin-top: auto;
64103
position: sticky;
65-
background-color: var(--bs-body-bg, white);
66104
bottom: 0;
105+
background: linear-gradient(to bottom, transparent, var(--bs-body-bg, white) calc(var(--_input-padding-top) - var(--_input-padding-bottom)));
106+
padding-block: var(--_input-padding-top) var(--_input-padding-bottom);
107+
67108
textarea {
68109
--bs-border-radius: 26px;
69110
resize: none;
@@ -75,7 +116,7 @@ shiny-chat-input {
75116
}
76117
button {
77118
position: absolute;
78-
bottom: 7px;
119+
bottom: calc(6px + var(--_input-padding-bottom));
79120
right: 8px;
80121
background-color: transparent;
81122
color: var(--bs-primary, #007bc2);

js/chat/chat.ts

Lines changed: 144 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type ShinyChatMessage = {
2626
type UpdateUserInput = {
2727
value?: string;
2828
placeholder?: string;
29+
submit?: false;
30+
focus?: false;
2931
};
3032

3133
// https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734
@@ -70,9 +72,28 @@ class ChatMessage extends LightElement {
7072
content-type=${this.content_type}
7173
?streaming=${this.streaming}
7274
auto-scroll
75+
.onContentChange=${this.#onContentChange}
76+
.onStreamEnd=${this.#makeSuggestionsAccessible}
7377
></shiny-markdown-stream>
7478
`;
7579
}
80+
81+
#onContentChange(): void {
82+
if (!this.streaming) this.#makeSuggestionsAccessible();
83+
}
84+
85+
#makeSuggestionsAccessible(): void {
86+
this.querySelectorAll(".suggestion,[data-suggestion]").forEach((el) => {
87+
if (!(el instanceof HTMLElement)) return;
88+
if (el.hasAttribute("tabindex")) return;
89+
90+
el.setAttribute("tabindex", "0");
91+
el.setAttribute("role", "button");
92+
93+
const suggestion = el.dataset.suggestion || el.textContent;
94+
el.setAttribute("aria-label", `Use chat suggestion: ${suggestion}`);
95+
});
96+
}
7697
}
7798

7899
class ChatUserMessage extends LightElement {
@@ -94,9 +115,46 @@ class ChatMessages extends LightElement {
94115
}
95116
}
96117

118+
interface ChatInputSetInputOptions {
119+
submit?: boolean;
120+
focus?: boolean;
121+
}
122+
97123
class ChatInput extends LightElement {
124+
private _disabled = false;
125+
98126
@property() placeholder = "Enter a message...";
99-
@property({ type: Boolean, reflect: true }) disabled = false;
127+
// disabled is reflected manually because `reflect: true` doesn't work with LightElement
128+
@property({ type: Boolean })
129+
get disabled() {
130+
return this._disabled;
131+
}
132+
133+
set disabled(value: boolean) {
134+
const oldValue = this._disabled;
135+
if (value === oldValue) {
136+
return;
137+
}
138+
139+
this._disabled = value;
140+
value
141+
? this.setAttribute("disabled", "")
142+
: this.removeAttribute("disabled");
143+
144+
this.requestUpdate("disabled", oldValue);
145+
this.#onInput();
146+
}
147+
148+
attributeChangedCallback(
149+
name: string,
150+
_old: string | null,
151+
value: string | null
152+
) {
153+
super.attributeChangedCallback(name, _old, value);
154+
if (name === "disabled") {
155+
this.disabled = value !== null;
156+
}
157+
}
100158

101159
private get textarea(): HTMLTextAreaElement {
102160
return this.querySelector("textarea") as HTMLTextAreaElement;
@@ -159,7 +217,7 @@ class ChatInput extends LightElement {
159217
this.#onInput();
160218
}
161219

162-
#sendInput(): void {
220+
#sendInput(focus = true): void {
163221
if (this.valueIsEmpty) return;
164222
if (this.disabled) return;
165223

@@ -174,22 +232,36 @@ class ChatInput extends LightElement {
174232
this.dispatchEvent(sentEvent);
175233

176234
this.setInputValue("");
235+
this.disabled = true;
177236

178-
this.textarea.focus();
237+
if (focus) this.textarea.focus();
179238
}
180239

181-
setInputValue(value: string): void {
240+
setInputValue(
241+
value: string,
242+
{ submit = false, focus = false }: ChatInputSetInputOptions = {}
243+
): void {
244+
// Store previous value to restore post-submit (if submitting)
245+
const oldValue = this.textarea.value;
246+
182247
this.textarea.value = value;
183-
this.disabled = value.trim().length === 0;
184248

185249
// Simulate an input event (to trigger the textarea autoresize)
186250
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
187251
this.textarea.dispatchEvent(inputEvent);
252+
253+
if (submit) {
254+
this.#sendInput(false);
255+
if (oldValue) this.setInputValue(oldValue);
256+
}
257+
258+
if (focus) {
259+
this.textarea.focus();
260+
}
188261
}
189262
}
190263

191264
class ChatContainer extends LightElement {
192-
@property() placeholder = "Enter a message...";
193265

194266
private get input(): ChatInput {
195267
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
@@ -227,6 +299,8 @@ class ChatContainer extends LightElement {
227299
"shiny-chat-remove-loading-message",
228300
this.#onRemoveLoadingMessage
229301
);
302+
this.addEventListener("click", this.#onInputSuggestionClick);
303+
this.addEventListener("keydown", this.#onInputSuggestionKeydown);
230304
}
231305

232306
disconnectedCallback(): void {
@@ -247,6 +321,8 @@ class ChatContainer extends LightElement {
247321
"shiny-chat-remove-loading-message",
248322
this.#onRemoveLoadingMessage
249323
);
324+
this.removeEventListener("click", this.#onInputSuggestionClick);
325+
this.removeEventListener("keydown", this.#onInputSuggestionKeydown);
250326
}
251327

252328
// When user submits input, append it to the chat, and add a loading message
@@ -260,8 +336,15 @@ class ChatContainer extends LightElement {
260336
this.#appendMessage(event.detail);
261337
}
262338

263-
#appendMessage(message: Message, finalize = true): void {
339+
#initMessage(): void {
264340
this.#removeLoadingMessage();
341+
if (!this.input.disabled) {
342+
this.input.disabled = true;
343+
}
344+
}
345+
346+
#appendMessage(message: Message, finalize = true): void {
347+
this.#initMessage();
265348

266349
const TAG_NAME =
267350
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;
@@ -323,15 +406,67 @@ class ChatContainer extends LightElement {
323406
}
324407

325408
#onUpdateUserInput(event: CustomEvent<UpdateUserInput>): void {
326-
const { value, placeholder } = event.detail;
409+
const { value, placeholder, submit, focus } = event.detail;
327410
if (value !== undefined) {
328-
this.input.setInputValue(value);
411+
this.input.setInputValue(value, { submit, focus });
329412
}
330413
if (placeholder !== undefined) {
331414
this.input.placeholder = placeholder;
332415
}
333416
}
334417

418+
#onInputSuggestionClick(e: MouseEvent): void {
419+
this.#onInputSuggestionEvent(e);
420+
}
421+
422+
#onInputSuggestionKeydown(e: KeyboardEvent): void {
423+
const isEnterOrSpace = e.key === "Enter" || e.key === " ";
424+
if (!isEnterOrSpace) return;
425+
426+
this.#onInputSuggestionEvent(e);
427+
}
428+
429+
#onInputSuggestionEvent(e: MouseEvent | KeyboardEvent): void {
430+
const { suggestion, submit } = this.#getSuggestion(e.target);
431+
if (!suggestion) return;
432+
433+
e.preventDefault();
434+
// Cmd/Ctrl + (event) = force submitting
435+
// Alt/Opt + (event) = force setting without submitting
436+
const shouldSubmit =
437+
e.metaKey || e.ctrlKey ? true : e.altKey ? false : submit;
438+
439+
this.input.setInputValue(suggestion, {
440+
submit: shouldSubmit,
441+
focus: !shouldSubmit,
442+
});
443+
}
444+
445+
#getSuggestion(x: EventTarget | null): {
446+
suggestion?: string;
447+
submit?: boolean;
448+
} {
449+
if (!(x instanceof HTMLElement)) return {};
450+
451+
const el = x.closest(".suggestion, [data-suggestion]");
452+
if (!(el instanceof HTMLElement)) return {};
453+
454+
const isSuggestion =
455+
el.classList.contains("suggestion") ||
456+
el.dataset.suggestion !== undefined;
457+
if (!isSuggestion) return {};
458+
459+
const suggestion = el.dataset.suggestion || el.textContent;
460+
461+
return {
462+
suggestion: suggestion || undefined,
463+
submit:
464+
el.classList.contains("submit") ||
465+
el.dataset.suggestionSubmit === "" ||
466+
el.dataset.suggestionSubmit === "true",
467+
};
468+
}
469+
335470
#onRemoveLoadingMessage(): void {
336471
this.#removeLoadingMessage();
337472
this.#finalizeMessage();

js/markdown-stream/markdown-stream.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ class MarkdownElement extends LightElement {
7979
streaming = false;
8080
@property({ type: Boolean, reflect: true, attribute: "auto-scroll" })
8181
auto_scroll = false;
82+
@property({ type: Function }) onContentChange?: () => void;
83+
@property({ type: Function }) onStreamEnd?: () => void;
8284

8385
render() {
8486
return html`${contentToHTML(this.content, this.content_type)}`;
@@ -112,10 +114,29 @@ class MarkdownElement extends LightElement {
112114
// Possibly scroll to bottom after content has been added
113115
this.#isContentBeingAdded = false;
114116
this.#maybeScrollToBottom();
117+
118+
if (this.onContentChange) {
119+
try {
120+
this.onContentChange();
121+
} catch (error) {
122+
console.warn("Failed to call onContentUpdate callback:", error);
123+
}
124+
}
115125
}
116126

117127
if (changedProperties.has("streaming")) {
118-
this.streaming ? this.#appendStreamingDot() : this.#removeStreamingDot();
128+
if (this.streaming) {
129+
this.#appendStreamingDot();
130+
} else {
131+
this.#removeStreamingDot();
132+
if (this.onStreamEnd) {
133+
try {
134+
this.onStreamEnd();
135+
} catch (error) {
136+
console.warn("Failed to call onStreamEnd callback:", error);
137+
}
138+
}
139+
}
119140
}
120141
}
121142

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ dependencies = [
3232
"uvicorn>=0.16.0;platform_system!='Emscripten'",
3333
"starlette",
3434
"websockets>=13.0",
35-
"python-multipart",
3635
"htmltools>=0.6.0",
3736
"click>=8.1.4;platform_system!='Emscripten'",
3837
"markdown-it-py>=1.1.0",

0 commit comments

Comments
 (0)