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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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)

Expand All @@ -26,6 +27,8 @@ different ways (see #1845 for more details):

* 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
13 changes: 11 additions & 2 deletions js/chat/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,17 @@ shiny-chat-container {
}

&::after {
content: "\21B5";
margin-inline-start: 0.25rem;
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
}
}
}
}
Expand Down
133 changes: 109 additions & 24 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 @@ -113,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 @@ -178,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 @@ -193,17 +232,32 @@ 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();
}
}
}

Expand Down Expand Up @@ -283,8 +337,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 @@ -346,41 +407,65 @@ 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: Event): void {
const suggestion = this.#getSuggestion(e.target);
if (!suggestion) return;

e.preventDefault();
this.input.setInputValue(suggestion);
#onInputSuggestionClick(e: MouseEvent): void {
this.#onInputSuggestionEvent(e);
}

#onInputSuggestionKeydown(e: KeyboardEvent): void {
const isEnter = e.key === "Enter" || e.key === " ";
if (!isEnter) return;
const suggestion = this.#getSuggestion(e.target);
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();
this.input.setInputValue(suggestion);
// 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): string | null {
if (!(x instanceof HTMLElement)) return null;
#getSuggestion(x: EventTarget | null): {
suggestion?: string;
submit?: boolean;
} {
if (!(x instanceof HTMLElement)) return {};

const isSuggestion =
x.classList.contains("suggestion") || x.dataset.suggestion !== undefined;
if (!isSuggestion) return null;
const el = x.closest(".suggestion, [data-suggestion]");
if (!(el instanceof HTMLElement)) return {};

return x.dataset.suggestion || x.textContent;
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 {
Expand Down
29 changes: 23 additions & 6 deletions shiny/ui/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,6 @@ async def _transform_message(
chunk: ChunkOption = False,
chunk_content: str | None = None,
) -> TransformedMessage | None:

res = as_transformed_message(message)
key = res["transform_key"]

Expand Down Expand Up @@ -791,7 +790,6 @@ def _store_message(
chunk: ChunkOption = False,
index: int | None = None,
) -> None:

# Don't actually store chunks until the end
if chunk is True or chunk == "start":
return None
Expand All @@ -817,7 +815,6 @@ def _trim_messages(
token_limits: tuple[int, int],
format: MISSING_TYPE | ProviderMessageFormat,
) -> tuple[TransformedMessage, ...]:

n_total, n_reserve = token_limits
if n_total <= n_reserve:
raise ValueError(
Expand Down Expand Up @@ -878,7 +875,6 @@ def _trim_anthropic_messages(
self,
messages: tuple[TransformedMessage, ...],
) -> tuple[TransformedMessage, ...]:

if any(m["role"] == "system" for m in messages):
raise ValueError(
"Anthropic requires a system prompt to be specified in it's `.create()` method "
Expand Down Expand Up @@ -938,7 +934,12 @@ def _user_input(self) -> str:
return cast(str, self._session.input[id]())

def update_user_input(
self, *, value: str | None = None, placeholder: str | None = None
self,
*,
value: str | None = None,
placeholder: str | None = None,
submit: bool = False,
focus: bool = False,
):
"""
Update the user input.
Expand All @@ -949,9 +950,25 @@ def update_user_input(
The value to set the user input to.
placeholder
The placeholder text for the user input.
submit
Whether to automatically submit the text for the user. Requires `value`.
focus
Whether to move focus to the input element. Requires `value`.
"""

obj = _utils.drop_none({"value": value, "placeholder": placeholder})
if value is None and (submit or focus):
raise ValueError(
"An input `value` must be provided when `submit` or `focus` are `True`."
)

obj = _utils.drop_none(
{
"value": value,
"placeholder": placeholder,
"submit": submit,
"focus": focus,
}
)

_utils.run_coro_sync(
self._session.send_custom_message(
Expand Down
2 changes: 1 addition & 1 deletion shiny/www/py-shiny/chat/chat.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading