Skip to content

Commit

Permalink
Chatbot autoscroll (#9582)
Browse files Browse the repository at this point in the history
* Auto scroll on the Chatbot component

* Scroll down button's design

* Parameterize autoscroll

* add changeset

* Fix test

* Fix

* Fix the <Video> component to dispatch the load event after the metadata is loaded

* add changeset

* Add tick

* Fix

* Fix

* Add loadstart and loadeddata and remove load event forwarder from <Video> because <video> doesn't dispatch the load event

* Fix <Player> as well

* Fix

* Add pending_message as the scroll trigger and remove unnecessary tick

* Refactoring <Image>

* add changeset

* Fix

* Fix

* icon fix

* add changeset

---------

Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
Co-authored-by: Abubakar Abid <abubakar@huggingface.co>
Co-authored-by: Dawood <dawoodkhan82@gmail.com>
  • Loading branch information
4 people authored Oct 8, 2024
1 parent 430a26a commit 43a7f42
Show file tree
Hide file tree
Showing 12 changed files with 134 additions and 23 deletions.
9 changes: 9 additions & 0 deletions .changeset/lazy-clubs-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@gradio/chatbot": minor
"@gradio/icons": minor
"@gradio/image": minor
"@gradio/video": minor
"gradio": minor
---

feat:Chatbot autoscroll
3 changes: 3 additions & 0 deletions gradio/chat_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def __init__(
head_paths: str | Path | Sequence[str | Path] | None = None,
analytics_enabled: bool | None = None,
autofocus: bool = True,
autoscroll: bool = True,
concurrency_limit: int | None | Literal["default"] = "default",
fill_height: bool = True,
delete_cache: tuple[int, int] | None = None,
Expand Down Expand Up @@ -121,6 +122,7 @@ def __init__(
head_paths: Custom html code as a pathlib.Path to a html file or a list of such paths. This html files will be read, concatenated, and included in the head of the demo webpage. If the `head` parameter is also set, the html from `head` will be included first.
analytics_enabled: whether to allow basic telemetry. If None, will use GRADIO_ANALYTICS_ENABLED environment variable if defined, or default to True.
autofocus: if True, autofocuses to the textbox when the page loads.
autoscroll: If True, will automatically scroll to the bottom of the textbox when the value changes, unless the user scrolls up. If False, will not scroll to the bottom of the textbox when the value changes.
concurrency_limit: if set, this is the maximum number of chatbot submissions that can be running simultaneously. Can be set to None to mean no limit (any number of chatbot submissions can be running simultaneously). Set to "default" to use the default concurrency limit (defined by the `default_concurrency_limit` parameter in `.queue()`, which is 1 by default).
fill_height: if True, the chat interface will expand to the height of window.
delete_cache: a tuple corresponding [frequency, age] both expressed in number of seconds. Every `frequency` seconds, the temporary files created by this Blocks instance will be deleted if more than `age` seconds have passed since the file was created. For example, setting this to (86400, 86400) will delete temporary files every day. The cache will be deleted entirely when the server restarts. If None, no cache deletion will occur.
Expand Down Expand Up @@ -230,6 +232,7 @@ def __init__(
scale=1,
height=200 if fill_height else None,
type=self.type,
autoscroll=autoscroll,
examples=examples_messages if not self.additional_inputs else None,
)

Expand Down
3 changes: 3 additions & 0 deletions gradio/components/chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def __init__(
visible: bool = True,
elem_id: str | None = None,
elem_classes: list[str] | str | None = None,
autoscroll: bool = True,
render: bool = True,
key: int | str | None = None,
height: int | str | None = 400,
Expand Down Expand Up @@ -197,6 +198,7 @@ def __init__(
visible: If False, component will be hidden.
elem_id: An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles.
elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.
autoscroll: If True, will automatically scroll to the bottom of the textbox when the value changes, unless the user scrolls up. If False, will not scroll to the bottom of the textbox when the value changes.
render: If False, component will not render be rendered in the Blocks context. Should be used if the intention is to assign event listeners now but render the component later.
key: if assigned, will be used to assume identity across a re-render. Components that have the same key across a re-render will have their value preserved.
height: The height of the component, specified in pixels if a number is passed, or in CSS units if a string is passed. If messages exceed the height, the component will scroll.
Expand Down Expand Up @@ -236,6 +238,7 @@ def __init__(
self.data_model = ChatbotDataMessages
else:
self.data_model = ChatbotDataTuples
self.autoscroll = autoscroll
self.height = height
self.max_height = max_height
self.min_height = min_height
Expand Down
2 changes: 2 additions & 0 deletions js/chatbot/Index.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
export let type: "tuples" | "messages" = "tuples";
export let render_markdown = true;
export let line_breaks = true;
export let autoscroll = true;
export let _retryable = false;
export let _undoable = false;
export let latex_delimiters: {
Expand Down Expand Up @@ -145,6 +146,7 @@
{sanitize_html}
{bubble_full_width}
{line_breaks}
{autoscroll}
{layout}
{placeholder}
{examples}
Expand Down
103 changes: 86 additions & 17 deletions js/chatbot/shared/ChatBot.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import { dequal } from "dequal/lite";
import {
beforeUpdate,
afterUpdate,
createEventDispatcher,
type SvelteComponent,
Expand All @@ -23,7 +22,7 @@
} from "svelte";
import { Image } from "@gradio/image/shared";
import { Clear, Trash, Community } from "@gradio/icons";
import { Clear, Trash, Community, ScrollDownArrow } from "@gradio/icons";
import { IconButtonWrapper, IconButton } from "@gradio/atoms";
import type { SelectData, LikeData } from "@gradio/utils";
import type { ExampleMessage } from "../types";
Expand Down Expand Up @@ -72,6 +71,7 @@
export let bubble_full_width = true;
export let render_markdown = true;
export let line_breaks = true;
export let autoscroll = true;
export let theme_mode: "system" | "light" | "dark";
export let i18n: I18nFormatter;
export let layout: "bubble" | "panel" = "bubble";
Expand All @@ -91,7 +91,8 @@
});
let div: HTMLDivElement;
let autoscroll: boolean;
let show_scroll_button = false;
const dispatch = createEventDispatcher<{
change: undefined;
Expand All @@ -105,28 +106,64 @@
example_select: SelectData;
}>();
beforeUpdate(() => {
autoscroll =
div && div.offsetHeight + div.scrollTop > div.scrollHeight - 100;
});
function is_at_bottom(): boolean {
return div && div.offsetHeight + div.scrollTop > div.scrollHeight - 100;
}
async function scroll(): Promise<void> {
function scroll_to_bottom(): void {
if (!div) return;
await tick();
requestAnimationFrame(() => {
if (autoscroll) {
div?.scrollTo(0, div.scrollHeight);
}
});
div.scrollTo(0, div.scrollHeight);
show_scroll_button = false;
}
let scroll_after_component_load = false;
function on_child_component_load(): void {
if (scroll_after_component_load) {
scroll_to_bottom();
scroll_after_component_load = false;
}
}
async function scroll_on_value_update(): Promise<void> {
if (!autoscroll) return;
if (is_at_bottom()) {
// Child components may be loaded asynchronously,
// so trigger the scroll again after they load.
scroll_after_component_load = true;
await tick(); // Wait for the DOM to update so that the scrollHeight is correct
scroll_to_bottom();
} else {
show_scroll_button = true;
}
}
onMount(() => {
scroll_on_value_update();
});
$: if (value || pending_message || _components) {
scroll_on_value_update();
}
onMount(() => {
function handle_scroll(): void {
if (is_at_bottom()) {
show_scroll_button = false;
} else {
scroll_after_component_load = false;
}
}
div?.addEventListener("scroll", handle_scroll);
return () => {
div?.removeEventListener("scroll", handle_scroll);
};
});
let image_preview_source: string;
let image_preview_source_alt: string;
let is_image_preview_open = false;
$: if (value || autoscroll || _components) {
scroll();
}
afterUpdate(() => {
if (!div) return;
div.querySelectorAll("img").forEach((n) => {
Expand Down Expand Up @@ -348,6 +385,17 @@
{/if}
</div>

{#if show_scroll_button}
<div class="scroll-down-button-container">
<IconButton
Icon={ScrollDownArrow}
label="Scroll down"
size="large"
on:click={scroll_to_bottom}
/>
</div>
{/if}

<style>
.placeholder-content {
display: flex;
Expand Down Expand Up @@ -565,4 +613,25 @@
.panel-wrap :global(.message-row:first-child) {
padding-top: calc(var(--spacing-xxl) * 2);
}
.scroll-down-button-container {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
z-index: var(--layer-top);
}
.scroll-down-button-container :global(button) {
border-radius: 50%;
box-shadow: var(--shadow-drop);
transition:
box-shadow 0.2s ease-in-out,
transform 0.2s ease-in-out;
}
.scroll-down-button-container :global(button:hover) {
box-shadow:
var(--shadow-drop),
0 2px 2px rgba(0, 0, 0, 0.05);
transform: translateY(-2px);
}
</style>
15 changes: 15 additions & 0 deletions js/icons/src/ScrollDownArrow.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<svg
width="100%"
height="100%"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20L12 4M12 20L7 15M12 20L17 15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
1 change: 1 addition & 0 deletions js/icons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ export { default as Warning } from "./Warning.svelte";
export { default as Webcam } from "./Webcam.svelte";
export { default as Spinner } from "./Spinner.svelte";
export { default as Retry } from "./Retry.svelte";
export { default as ScrollDownArrow } from "./ScrollDownArrow.svelte";
4 changes: 1 addition & 3 deletions js/image/shared/Image.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<script lang="ts">
import type { HTMLImgAttributes } from "svelte/elements";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher<{ load?: void }>();
interface Props extends HTMLImgAttributes {
"data-testid"?: string;
}
Expand Down Expand Up @@ -37,7 +35,7 @@
</script>

<!-- svelte-ignore a11y-missing-attribute -->
<img src={resolved_src} {...$$restProps} on:load={() => dispatch("load")} />
<img src={resolved_src} {...$$restProps} on:load />

<style>
img {
Expand Down
4 changes: 3 additions & 1 deletion js/video/shared/Player.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@
bind:node={video}
data-testid={`${label}-player`}
{processingVideo}
on:load
on:loadstart
on:loadeddata
on:loadedmetadata
>
<track kind="captions" src={subtitle} default />
</Video>
Expand Down
4 changes: 3 additions & 1 deletion js/video/shared/Video.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ Then, even when `controls` is false, the compiled DOM would be `<video controls=
on:mouseout={dispatch.bind(null, "mouseout")}
on:focus={dispatch.bind(null, "focus")}
on:blur={dispatch.bind(null, "blur")}
on:load
on:loadstart
on:loadeddata
on:loadedmetadata
bind:currentTime
bind:duration
bind:paused
Expand Down
8 changes: 7 additions & 1 deletion js/video/shared/VideoPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
pause: undefined;
end: undefined;
stop: undefined;
load: undefined;
}>();
$: value && dispatch("change", value);
Expand Down Expand Up @@ -70,7 +71,12 @@
on:pause
on:stop
on:end
on:load
on:loadedmetadata={() => {
// Deal with `<video>`'s `loadedmetadata` event as `VideoPreview`'s `load` event
// to represent not only the video is loaded but also the metadata is loaded
// so its dimensions (w/h) are known. This is used for Chatbot's auto scroll.
dispatch("load");
}}
mirror={false}
{label}
{loop}
Expand Down
1 change: 1 addition & 0 deletions test/components/test_chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def test_component_functions(self):
"height": 400,
"max_height": None,
"min_height": None,
"autoscroll": True,
"proxy_url": None,
"_selectable": False,
"_retryable": False,
Expand Down

0 comments on commit 43a7f42

Please sign in to comment.