Skip to content

Commit 3a22683

Browse files
committed
Merge branch 'main' into fix/chat-input-scroll-background
2 parents eb2da25 + f540a30 commit 3a22683

File tree

17 files changed

+662
-72
lines changed

17 files changed

+662
-72
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's `.on_user_submit()` decorator method now passes the user input to the decorated function. This makes it a bit more obvious how to access the user input inside the decorated function. See the new templates (mentioned below) for examples. (#1801)
@@ -21,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2126

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

29+
* 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)
30+
2431
### Bug fixes
2532

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

js/chat/chat.scss

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,40 @@ shiny-chat-container {
1414
p:last-child {
1515
margin-bottom: 0;
1616
}
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+
}
1751
}
1852

1953
shiny-chat-messages {

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)