Skip to content

Commit 2b2f105

Browse files
committed
Merge branch 'main' into feat/chat-icon
2 parents 45e662e + f540a30 commit 2b2f105

File tree

17 files changed

+661
-67
lines changed

17 files changed

+661
-67
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
@@ -16,6 +16,40 @@ shiny-chat-container {
1616
[data-icon="assistant"] {
1717
display: none;
1818
}
19+
20+
.suggestion,
21+
[data-suggestion] {
22+
cursor: pointer;
23+
color: var(--bs-link-color, #007bc2);
24+
25+
text-decoration-color: var(--bs-link-color, #007bc2);
26+
text-decoration-line: underline;
27+
text-decoration-style: dotted;
28+
text-decoration-thickness: 2px;
29+
text-underline-offset: 2px;
30+
text-underline-offset: 4px;
31+
text-decoration-thickness: 2px;
32+
33+
padding: 2px;
34+
35+
&:hover {
36+
text-decoration-style: solid;
37+
}
38+
39+
&::after {
40+
content: "\2726"; // diamond/star
41+
display: inline-block;
42+
margin-inline-start: 0.15em;
43+
}
44+
45+
&.submit,
46+
&[data-suggestion-submit=""],
47+
&[data-suggestion-submit="true"] {
48+
&::after {
49+
content: "\21B5"; // return key symbol
50+
}
51+
}
52+
}
1953
}
2054

2155
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
@@ -77,9 +79,28 @@ class ChatMessage extends LightElement {
7779
content-type=${this.content_type}
7880
?streaming=${this.streaming}
7981
auto-scroll
82+
.onContentChange=${this.#onContentChange}
83+
.onStreamEnd=${this.#makeSuggestionsAccessible}
8084
></shiny-markdown-stream>
8185
`;
8286
}
87+
88+
#onContentChange(): void {
89+
if (!this.streaming) this.#makeSuggestionsAccessible();
90+
}
91+
92+
#makeSuggestionsAccessible(): void {
93+
this.querySelectorAll(".suggestion,[data-suggestion]").forEach((el) => {
94+
if (!(el instanceof HTMLElement)) return;
95+
if (el.hasAttribute("tabindex")) return;
96+
97+
el.setAttribute("tabindex", "0");
98+
el.setAttribute("role", "button");
99+
100+
const suggestion = el.dataset.suggestion || el.textContent;
101+
el.setAttribute("aria-label", `Use chat suggestion: ${suggestion}`);
102+
});
103+
}
83104
}
84105

85106
class ChatUserMessage extends LightElement {
@@ -101,9 +122,46 @@ class ChatMessages extends LightElement {
101122
}
102123
}
103124

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

108166
private get textarea(): HTMLTextAreaElement {
109167
return this.querySelector("textarea") as HTMLTextAreaElement;
@@ -166,7 +224,7 @@ class ChatInput extends LightElement {
166224
this.#onInput();
167225
}
168226

169-
#sendInput(): void {
227+
#sendInput(focus = true): void {
170228
if (this.valueIsEmpty) return;
171229
if (this.disabled) return;
172230

@@ -181,22 +239,36 @@ class ChatInput extends LightElement {
181239
this.dispatchEvent(sentEvent);
182240

183241
this.setInputValue("");
242+
this.disabled = true;
184243

185-
this.textarea.focus();
244+
if (focus) this.textarea.focus();
186245
}
187246

188-
setInputValue(value: string): void {
247+
setInputValue(
248+
value: string,
249+
{ submit = false, focus = false }: ChatInputSetInputOptions = {}
250+
): void {
251+
// Store previous value to restore post-submit (if submitting)
252+
const oldValue = this.textarea.value;
253+
189254
this.textarea.value = value;
190-
this.disabled = value.trim().length === 0;
191255

192256
// Simulate an input event (to trigger the textarea autoresize)
193257
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
194258
this.textarea.dispatchEvent(inputEvent);
259+
260+
if (submit) {
261+
this.#sendInput(false);
262+
if (oldValue) this.setInputValue(oldValue);
263+
}
264+
265+
if (focus) {
266+
this.textarea.focus();
267+
}
195268
}
196269
}
197270

198271
class ChatContainer extends LightElement {
199-
@property() placeholder = "Enter a message...";
200272

201273
private get input(): ChatInput {
202274
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
@@ -234,6 +306,8 @@ class ChatContainer extends LightElement {
234306
"shiny-chat-remove-loading-message",
235307
this.#onRemoveLoadingMessage
236308
);
309+
this.addEventListener("click", this.#onInputSuggestionClick);
310+
this.addEventListener("keydown", this.#onInputSuggestionKeydown);
237311
}
238312

239313
disconnectedCallback(): void {
@@ -254,6 +328,8 @@ class ChatContainer extends LightElement {
254328
"shiny-chat-remove-loading-message",
255329
this.#onRemoveLoadingMessage
256330
);
331+
this.removeEventListener("click", this.#onInputSuggestionClick);
332+
this.removeEventListener("keydown", this.#onInputSuggestionKeydown);
257333
}
258334

259335
// When user submits input, append it to the chat, and add a loading message
@@ -267,8 +343,15 @@ class ChatContainer extends LightElement {
267343
this.#appendMessage(event.detail);
268344
}
269345

270-
#appendMessage(message: Message, finalize = true): void {
346+
#initMessage(): void {
271347
this.#removeLoadingMessage();
348+
if (!this.input.disabled) {
349+
this.input.disabled = true;
350+
}
351+
}
352+
353+
#appendMessage(message: Message, finalize = true): void {
354+
this.#initMessage();
272355

273356
const TAG_NAME =
274357
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;
@@ -340,15 +423,67 @@ class ChatContainer extends LightElement {
340423
}
341424

342425
#onUpdateUserInput(event: CustomEvent<UpdateUserInput>): void {
343-
const { value, placeholder } = event.detail;
426+
const { value, placeholder, submit, focus } = event.detail;
344427
if (value !== undefined) {
345-
this.input.setInputValue(value);
428+
this.input.setInputValue(value, { submit, focus });
346429
}
347430
if (placeholder !== undefined) {
348431
this.input.placeholder = placeholder;
349432
}
350433
}
351434

435+
#onInputSuggestionClick(e: MouseEvent): void {
436+
this.#onInputSuggestionEvent(e);
437+
}
438+
439+
#onInputSuggestionKeydown(e: KeyboardEvent): void {
440+
const isEnterOrSpace = e.key === "Enter" || e.key === " ";
441+
if (!isEnterOrSpace) return;
442+
443+
this.#onInputSuggestionEvent(e);
444+
}
445+
446+
#onInputSuggestionEvent(e: MouseEvent | KeyboardEvent): void {
447+
const { suggestion, submit } = this.#getSuggestion(e.target);
448+
if (!suggestion) return;
449+
450+
e.preventDefault();
451+
// Cmd/Ctrl + (event) = force submitting
452+
// Alt/Opt + (event) = force setting without submitting
453+
const shouldSubmit =
454+
e.metaKey || e.ctrlKey ? true : e.altKey ? false : submit;
455+
456+
this.input.setInputValue(suggestion, {
457+
submit: shouldSubmit,
458+
focus: !shouldSubmit,
459+
});
460+
}
461+
462+
#getSuggestion(x: EventTarget | null): {
463+
suggestion?: string;
464+
submit?: boolean;
465+
} {
466+
if (!(x instanceof HTMLElement)) return {};
467+
468+
const el = x.closest(".suggestion, [data-suggestion]");
469+
if (!(el instanceof HTMLElement)) return {};
470+
471+
const isSuggestion =
472+
el.classList.contains("suggestion") ||
473+
el.dataset.suggestion !== undefined;
474+
if (!isSuggestion) return {};
475+
476+
const suggestion = el.dataset.suggestion || el.textContent;
477+
478+
return {
479+
suggestion: suggestion || undefined,
480+
submit:
481+
el.classList.contains("submit") ||
482+
el.dataset.suggestionSubmit === "" ||
483+
el.dataset.suggestionSubmit === "true",
484+
};
485+
}
486+
352487
#onRemoveLoadingMessage(): void {
353488
this.#removeLoadingMessage();
354489
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)