Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* 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)

* 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):
* By adding a `.suggestion` CSS class to an HTML element (e.g., `<span class="suggestion">A suggestion</span>`)
* 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>`)
* 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.

* 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)

* 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)
Expand All @@ -21,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

* 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)

### Bug fixes

* `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)
Expand Down
34 changes: 34 additions & 0 deletions js/chat/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,40 @@ shiny-chat-container {
p:last-child {
margin-bottom: 0;
}

.suggestion,
[data-suggestion] {
cursor: pointer;
color: var(--bs-link-color, #007bc2);

text-decoration-color: var(--bs-link-color, #007bc2);
text-decoration-line: underline;
text-decoration-style: dotted;
text-decoration-thickness: 2px;
text-underline-offset: 2px;
text-underline-offset: 4px;
text-decoration-thickness: 2px;

padding: 2px;

&:hover {
text-decoration-style: solid;
}

&::after {
content: "\2726"; // diamond/star
display: inline-block;
margin-inline-start: 0.15em;
}

&.submit,
&[data-suggestion-submit=""],
&[data-suggestion-submit="true"] {
&::after {
content: "\21B5"; // return key symbol
}
}
}
}

shiny-chat-messages {
Expand Down
153 changes: 144 additions & 9 deletions js/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type ShinyChatMessage = {
type UpdateUserInput = {
value?: string;
placeholder?: string;
submit?: false;
focus?: false;
};

// https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734
Expand Down Expand Up @@ -70,9 +72,28 @@ class ChatMessage extends LightElement {
content-type=${this.content_type}
?streaming=${this.streaming}
auto-scroll
.onContentChange=${this.#onContentChange}
.onStreamEnd=${this.#makeSuggestionsAccessible}
></shiny-markdown-stream>
`;
}

#onContentChange(): void {
if (!this.streaming) this.#makeSuggestionsAccessible();
}

#makeSuggestionsAccessible(): void {
this.querySelectorAll(".suggestion,[data-suggestion]").forEach((el) => {
if (!(el instanceof HTMLElement)) return;
if (el.hasAttribute("tabindex")) return;

el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");

const suggestion = el.dataset.suggestion || el.textContent;
el.setAttribute("aria-label", `Use chat suggestion: ${suggestion}`);
});
}
}

class ChatUserMessage extends LightElement {
Expand All @@ -94,9 +115,46 @@ class ChatMessages extends LightElement {
}
}

interface ChatInputSetInputOptions {
submit?: boolean;
focus?: boolean;
}

class ChatInput extends LightElement {
private _disabled = false;

@property() placeholder = "Enter a message...";
@property({ type: Boolean, reflect: true }) disabled = false;
// disabled is reflected manually because `reflect: true` doesn't work with LightElement
@property({ type: Boolean })
get disabled() {
return this._disabled;
}

set disabled(value: boolean) {
const oldValue = this._disabled;
if (value === oldValue) {
return;
}

this._disabled = value;
value
? this.setAttribute("disabled", "")
: this.removeAttribute("disabled");

this.requestUpdate("disabled", oldValue);
this.#onInput();
}

attributeChangedCallback(
name: string,
_old: string | null,
value: string | null
) {
super.attributeChangedCallback(name, _old, value);
if (name === "disabled") {
this.disabled = value !== null;
}
}

private get textarea(): HTMLTextAreaElement {
return this.querySelector("textarea") as HTMLTextAreaElement;
Expand Down Expand Up @@ -159,7 +217,7 @@ class ChatInput extends LightElement {
this.#onInput();
}

#sendInput(): void {
#sendInput(focus = true): void {
if (this.valueIsEmpty) return;
if (this.disabled) return;

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

this.setInputValue("");
this.disabled = true;

this.textarea.focus();
if (focus) this.textarea.focus();
}

setInputValue(value: string): void {
setInputValue(
value: string,
{ submit = false, focus = false }: ChatInputSetInputOptions = {}
): void {
// Store previous value to restore post-submit (if submitting)
const oldValue = this.textarea.value;

this.textarea.value = value;
this.disabled = value.trim().length === 0;

// Simulate an input event (to trigger the textarea autoresize)
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
this.textarea.dispatchEvent(inputEvent);

if (submit) {
this.#sendInput(false);
if (oldValue) this.setInputValue(oldValue);
}

if (focus) {
this.textarea.focus();
}
}
}

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

private get input(): ChatInput {
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
Expand Down Expand Up @@ -227,6 +299,8 @@ class ChatContainer extends LightElement {
"shiny-chat-remove-loading-message",
this.#onRemoveLoadingMessage
);
this.addEventListener("click", this.#onInputSuggestionClick);
this.addEventListener("keydown", this.#onInputSuggestionKeydown);
}

disconnectedCallback(): void {
Expand All @@ -247,6 +321,8 @@ class ChatContainer extends LightElement {
"shiny-chat-remove-loading-message",
this.#onRemoveLoadingMessage
);
this.removeEventListener("click", this.#onInputSuggestionClick);
this.removeEventListener("keydown", this.#onInputSuggestionKeydown);
}

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

#appendMessage(message: Message, finalize = true): void {
#initMessage(): void {
this.#removeLoadingMessage();
if (!this.input.disabled) {
this.input.disabled = true;
}
}

#appendMessage(message: Message, finalize = true): void {
this.#initMessage();

const TAG_NAME =
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;
Expand Down Expand Up @@ -323,15 +406,67 @@ class ChatContainer extends LightElement {
}

#onUpdateUserInput(event: CustomEvent<UpdateUserInput>): void {
const { value, placeholder } = event.detail;
const { value, placeholder, submit, focus } = event.detail;
if (value !== undefined) {
this.input.setInputValue(value);
this.input.setInputValue(value, { submit, focus });
}
if (placeholder !== undefined) {
this.input.placeholder = placeholder;
}
}

#onInputSuggestionClick(e: MouseEvent): void {
this.#onInputSuggestionEvent(e);
}

#onInputSuggestionKeydown(e: KeyboardEvent): void {
const isEnterOrSpace = e.key === "Enter" || e.key === " ";
if (!isEnterOrSpace) return;

this.#onInputSuggestionEvent(e);
}

#onInputSuggestionEvent(e: MouseEvent | KeyboardEvent): void {
const { suggestion, submit } = this.#getSuggestion(e.target);
if (!suggestion) return;

e.preventDefault();
// Cmd/Ctrl + (event) = force submitting
// Alt/Opt + (event) = force setting without submitting
const shouldSubmit =
e.metaKey || e.ctrlKey ? true : e.altKey ? false : submit;

this.input.setInputValue(suggestion, {
submit: shouldSubmit,
focus: !shouldSubmit,
});
}

#getSuggestion(x: EventTarget | null): {
suggestion?: string;
submit?: boolean;
} {
if (!(x instanceof HTMLElement)) return {};

const el = x.closest(".suggestion, [data-suggestion]");
if (!(el instanceof HTMLElement)) return {};

const isSuggestion =
el.classList.contains("suggestion") ||
el.dataset.suggestion !== undefined;
if (!isSuggestion) return {};

const suggestion = el.dataset.suggestion || el.textContent;

return {
suggestion: suggestion || undefined,
submit:
el.classList.contains("submit") ||
el.dataset.suggestionSubmit === "" ||
el.dataset.suggestionSubmit === "true",
};
}

#onRemoveLoadingMessage(): void {
this.#removeLoadingMessage();
this.#finalizeMessage();
Expand Down
23 changes: 22 additions & 1 deletion js/markdown-stream/markdown-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ class MarkdownElement extends LightElement {
streaming = false;
@property({ type: Boolean, reflect: true, attribute: "auto-scroll" })
auto_scroll = false;
@property({ type: Function }) onContentChange?: () => void;
@property({ type: Function }) onStreamEnd?: () => void;

render() {
return html`${contentToHTML(this.content, this.content_type)}`;
Expand Down Expand Up @@ -112,10 +114,29 @@ class MarkdownElement extends LightElement {
// Possibly scroll to bottom after content has been added
this.#isContentBeingAdded = false;
this.#maybeScrollToBottom();

if (this.onContentChange) {
try {
this.onContentChange();
} catch (error) {
console.warn("Failed to call onContentUpdate callback:", error);
}
}
}

if (changedProperties.has("streaming")) {
this.streaming ? this.#appendStreamingDot() : this.#removeStreamingDot();
if (this.streaming) {
this.#appendStreamingDot();
} else {
this.#removeStreamingDot();
if (this.onStreamEnd) {
try {
this.onStreamEnd();
} catch (error) {
console.warn("Failed to call onStreamEnd callback:", error);
}
}
}
}
}

Expand Down
Loading
Loading