adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
593 lines
15 KiB
Svelte
593 lines
15 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
format_chat_for_sharing,
|
|
type UndoRetryData,
|
|
type EditData,
|
|
is_last_bot_message,
|
|
group_messages,
|
|
load_components,
|
|
get_components_from_messages
|
|
} from "./utils";
|
|
import type { NormalisedMessage, Option } from "../types";
|
|
import { copy } from "@gradio/utils";
|
|
import type { CopyData } from "@gradio/utils";
|
|
import Message from "./Message.svelte";
|
|
|
|
import { dequal } from "dequal/lite";
|
|
import {
|
|
type SvelteComponent,
|
|
type ComponentType,
|
|
tick,
|
|
untrack
|
|
} from "svelte";
|
|
|
|
import { Trash, Community, ScrollDownArrow } from "@gradio/icons";
|
|
import { IconButtonWrapper, IconButton } from "@gradio/atoms";
|
|
import type { CustomButton as CustomButtonType } from "@gradio/utils";
|
|
import type { SelectData, LikeData } from "@gradio/utils";
|
|
import type { ExampleMessage } from "../types";
|
|
import type { FileData, Client } from "@gradio/client";
|
|
import type { I18nFormatter } from "js/core/src/gradio_helper";
|
|
import Pending from "./Pending.svelte";
|
|
import { ShareError } from "@gradio/utils";
|
|
import { Gradio } from "@gradio/utils";
|
|
|
|
import Examples from "./Examples.svelte";
|
|
import CopyAll from "./CopyAll.svelte";
|
|
|
|
let {
|
|
value = null,
|
|
_fetch,
|
|
load_component,
|
|
allow_file_downloads,
|
|
display_consecutive_in_same_bubble,
|
|
latex_delimiters,
|
|
pending_message = false,
|
|
generating = false,
|
|
selectable = false,
|
|
likeable = false,
|
|
feedback_options,
|
|
feedback_value = null,
|
|
editable = null,
|
|
show_share_button = false,
|
|
show_copy_all_button = false,
|
|
rtl = false,
|
|
show_copy_button = false,
|
|
buttons = null,
|
|
on_custom_button_click = null,
|
|
avatar_images = [null, null] as [FileData | null, FileData | null],
|
|
sanitize_html = true,
|
|
render_markdown = true,
|
|
line_breaks = true,
|
|
autoscroll = true,
|
|
theme_mode,
|
|
i18n,
|
|
layout = "bubble",
|
|
placeholder = null,
|
|
upload,
|
|
examples = null,
|
|
_retryable = false,
|
|
_undoable = false,
|
|
like_user_message = false,
|
|
allow_tags = false,
|
|
watermark = null,
|
|
show_progress = "full",
|
|
onchange,
|
|
onselect,
|
|
onlike,
|
|
onedit,
|
|
onundo,
|
|
onretry,
|
|
onclear,
|
|
onshare,
|
|
onerror,
|
|
onexampleselect,
|
|
onoptionselect,
|
|
oncopy
|
|
}: {
|
|
value?: NormalisedMessage[] | null;
|
|
_fetch: typeof fetch;
|
|
load_component: Gradio["load_component"];
|
|
allow_file_downloads: boolean;
|
|
display_consecutive_in_same_bubble: boolean;
|
|
latex_delimiters: {
|
|
left: string;
|
|
right: string;
|
|
display: boolean;
|
|
}[];
|
|
pending_message?: boolean;
|
|
generating?: boolean;
|
|
selectable?: boolean;
|
|
likeable?: boolean;
|
|
feedback_options: string[];
|
|
feedback_value?: (string | null)[] | null;
|
|
editable?: "user" | "all" | null;
|
|
show_share_button?: boolean;
|
|
show_copy_all_button?: boolean;
|
|
rtl?: boolean;
|
|
show_copy_button?: boolean;
|
|
buttons?: (string | CustomButtonType)[] | null;
|
|
on_custom_button_click?: ((id: number) => void) | null;
|
|
avatar_images?: [FileData | null, FileData | null];
|
|
sanitize_html?: boolean;
|
|
render_markdown?: boolean;
|
|
line_breaks?: boolean;
|
|
autoscroll?: boolean;
|
|
theme_mode: "system" | "light" | "dark";
|
|
i18n: I18nFormatter;
|
|
layout?: "bubble" | "panel";
|
|
placeholder?: string | null;
|
|
upload: Client["upload"];
|
|
examples?: ExampleMessage[] | null;
|
|
_retryable?: boolean;
|
|
_undoable?: boolean;
|
|
like_user_message?: boolean;
|
|
allow_tags?: string[] | boolean;
|
|
watermark?: string | null;
|
|
show_progress?: "full" | "minimal" | "hidden";
|
|
onchange?: () => void;
|
|
onselect?: (data: SelectData) => void;
|
|
onlike?: (data: LikeData) => void;
|
|
onedit?: (data: EditData) => void;
|
|
onundo?: (data: UndoRetryData) => void;
|
|
onretry?: (data: UndoRetryData) => void;
|
|
onclear?: () => void;
|
|
onshare?: (data: any) => void;
|
|
onerror?: (message: string) => void;
|
|
onexampleselect?: (data: SelectData) => void;
|
|
onoptionselect?: (data: SelectData) => void;
|
|
oncopy?: (data: CopyData) => void;
|
|
} = $props();
|
|
|
|
let old_value: NormalisedMessage[] | null = $state(null);
|
|
let _components: Record<string, ComponentType<SvelteComponent>> = $state({});
|
|
let component_names = $derived(
|
|
get_components_from_messages(value).sort().join(",")
|
|
);
|
|
|
|
async function update_components(component_names: string): Promise<void> {
|
|
_components = await load_components(
|
|
component_names ? component_names.split(",") : [],
|
|
_components,
|
|
load_component
|
|
);
|
|
}
|
|
|
|
$effect(() => {
|
|
const names = component_names;
|
|
untrack(() => update_components(names));
|
|
});
|
|
|
|
const is_browser = typeof window !== "undefined";
|
|
|
|
let target: HTMLElement | null = $state(null);
|
|
let edit_index: number | null = $state(null);
|
|
let edit_messages: string[] = $state([]);
|
|
|
|
$effect(() => {
|
|
target = document.querySelector("div.gradio-container");
|
|
});
|
|
|
|
let div: HTMLDivElement;
|
|
let show_scroll_button = $state(false);
|
|
|
|
function is_at_bottom(): boolean {
|
|
return div && div.offsetHeight + div.scrollTop > div.scrollHeight - 100;
|
|
}
|
|
|
|
function scroll_to_bottom(): void {
|
|
if (!div) return;
|
|
div.scrollTo(0, div.scrollHeight);
|
|
show_scroll_button = false;
|
|
}
|
|
|
|
let scroll_after_component_load = $state(false);
|
|
let has_scrolled_on_mount = false;
|
|
|
|
function scroll_to_bottom_after_component_load(): void {
|
|
if (scroll_after_component_load) {
|
|
scroll_to_bottom();
|
|
}
|
|
}
|
|
|
|
async function scroll_on_value_update(): Promise<void> {
|
|
if (!autoscroll) return;
|
|
if (is_at_bottom()) {
|
|
scroll_after_component_load = true;
|
|
await tick();
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
scroll_to_bottom();
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
value;
|
|
pending_message;
|
|
_components;
|
|
|
|
if (!has_scrolled_on_mount) {
|
|
has_scrolled_on_mount = true;
|
|
if (autoscroll && div) {
|
|
scroll_to_bottom();
|
|
}
|
|
}
|
|
|
|
scroll_on_value_update();
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!div) return;
|
|
function handle_scroll(): void {
|
|
if (is_at_bottom()) {
|
|
show_scroll_button = false;
|
|
} else {
|
|
scroll_after_component_load = false;
|
|
show_scroll_button = true;
|
|
}
|
|
}
|
|
|
|
div.addEventListener("scroll", handle_scroll);
|
|
return () => {
|
|
div.removeEventListener("scroll", handle_scroll);
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!dequal(value, old_value)) {
|
|
old_value = value;
|
|
onchange?.();
|
|
}
|
|
});
|
|
|
|
let groupedMessages = $derived(
|
|
value ? group_messages(value, display_consecutive_in_same_bubble) : null
|
|
);
|
|
|
|
let options = $derived.by(() => {
|
|
if (!value || !groupedMessages || groupedMessages.length === 0)
|
|
return undefined;
|
|
const last_group = groupedMessages[groupedMessages.length - 1];
|
|
if (last_group[0].role !== "assistant") return undefined;
|
|
return last_group[last_group.length - 1].options;
|
|
});
|
|
|
|
function handle_action(
|
|
i: number,
|
|
message: NormalisedMessage,
|
|
selected: string | null
|
|
): void {
|
|
if (selected === "undo" || selected === "retry") {
|
|
const val_ = value as NormalisedMessage[];
|
|
let last_index = val_.length - 1;
|
|
while (val_[last_index].role === "assistant") {
|
|
last_index--;
|
|
}
|
|
const handler = selected === "undo" ? onundo : onretry;
|
|
handler?.({
|
|
index: val_[last_index].index,
|
|
value: val_[last_index].content
|
|
});
|
|
} else if (selected == "edit") {
|
|
edit_index = i;
|
|
edit_messages.push(message.content as string);
|
|
} else if (selected == "edit_cancel") {
|
|
edit_index = null;
|
|
} else if (selected == "edit_submit") {
|
|
edit_index = null;
|
|
onedit?.({
|
|
index: message.index,
|
|
_dispatch_value: [{ type: "text", text: edit_messages[i].slice() }],
|
|
value: edit_messages[i].slice(),
|
|
previous_value: message.content as string
|
|
});
|
|
} else {
|
|
let feedback =
|
|
selected === "Like"
|
|
? true
|
|
: selected === "Dislike"
|
|
? false
|
|
: selected || "";
|
|
if (!groupedMessages) return;
|
|
|
|
const message_group = groupedMessages[i];
|
|
const [first] = [
|
|
message_group[0],
|
|
message_group[message_group.length - 1]
|
|
];
|
|
|
|
onlike?.({
|
|
index: first.index as number,
|
|
value: message_group.map((m) => m.content),
|
|
liked: feedback
|
|
});
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if value !== null && value.length > 0}
|
|
<IconButtonWrapper {buttons} {on_custom_button_click}>
|
|
{#if show_share_button}
|
|
<IconButton
|
|
Icon={Community}
|
|
onclick={async () => {
|
|
try {
|
|
// @ts-ignore
|
|
const formatted = await format_chat_for_sharing(value);
|
|
onshare?.({
|
|
description: formatted
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
let message = e instanceof ShareError ? e.message : "Share failed.";
|
|
onerror?.(message);
|
|
}
|
|
}}
|
|
/>
|
|
{/if}
|
|
<IconButton
|
|
Icon={Trash}
|
|
onclick={() => onclear?.()}
|
|
label={i18n("chatbot.clear")}
|
|
/>
|
|
{#if show_copy_all_button}
|
|
<CopyAll {value} {watermark} />
|
|
{/if}
|
|
</IconButtonWrapper>
|
|
{/if}
|
|
|
|
<div
|
|
class={layout === "bubble" ? "bubble-wrap" : "panel-wrap"}
|
|
bind:this={div}
|
|
role="log"
|
|
aria-label="chatbot conversation"
|
|
aria-live="polite"
|
|
>
|
|
{#if value !== null && value.length > 0 && groupedMessages !== null}
|
|
<div class="message-wrap" use:copy>
|
|
{#each groupedMessages as messages, i}
|
|
{@const role = messages[0].role === "user" ? "user" : "bot"}
|
|
{@const avatar_img = avatar_images[role === "user" ? 0 : 1]}
|
|
{@const opposite_avatar_img = avatar_images[role === "user" ? 0 : 1]}
|
|
{@const feedback_index = groupedMessages
|
|
.slice(0, i)
|
|
.filter((m) => m[0].role === "assistant").length}
|
|
{@const current_feedback =
|
|
role === "bot" && feedback_value && feedback_value[feedback_index]
|
|
? feedback_value[feedback_index]
|
|
: null}
|
|
<Message
|
|
{messages}
|
|
{display_consecutive_in_same_bubble}
|
|
{opposite_avatar_img}
|
|
{avatar_img}
|
|
{role}
|
|
{layout}
|
|
{onselect}
|
|
{i18n}
|
|
{_fetch}
|
|
{line_breaks}
|
|
{theme_mode}
|
|
{target}
|
|
{upload}
|
|
{selectable}
|
|
{sanitize_html}
|
|
{render_markdown}
|
|
{rtl}
|
|
{i}
|
|
{value}
|
|
{latex_delimiters}
|
|
{_components}
|
|
{generating}
|
|
{feedback_options}
|
|
{current_feedback}
|
|
{allow_tags}
|
|
{watermark}
|
|
show_like={role === "user" ? likeable && like_user_message : likeable}
|
|
show_retry={_retryable && is_last_bot_message(messages, value)}
|
|
show_undo={_undoable && is_last_bot_message(messages, value)}
|
|
show_edit={editable === "all" ||
|
|
(editable == "user" &&
|
|
role === "user" &&
|
|
messages.length > 0 &&
|
|
messages[messages.length - 1].type == "text")}
|
|
in_edit_mode={edit_index === i}
|
|
bind:edit_messages
|
|
{show_copy_button}
|
|
handle_action={(selected) => {
|
|
if (selected == "edit") {
|
|
edit_messages.splice(0, edit_messages.length);
|
|
}
|
|
if (selected === "edit" || selected === "edit_submit") {
|
|
messages.forEach((msg, index) => {
|
|
handle_action(selected === "edit" ? i : index, msg, selected);
|
|
});
|
|
} else {
|
|
handle_action(i, messages[0], selected);
|
|
}
|
|
}}
|
|
scroll={is_browser ? scroll_to_bottom_after_component_load : () => {}}
|
|
{allow_file_downloads}
|
|
{onerror}
|
|
{onshare}
|
|
{oncopy}
|
|
/>
|
|
{#if show_progress !== "hidden" && generating && messages[messages.length - 1].role === "assistant" && messages[messages.length - 1].metadata?.status === "done"}
|
|
<Pending {layout} {avatar_images} />
|
|
{/if}
|
|
{/each}
|
|
{#if show_progress !== "hidden" && pending_message}
|
|
<Pending {layout} {avatar_images} />
|
|
{:else if options}
|
|
<div class="options">
|
|
{#each options as option, index}
|
|
<button
|
|
class="option"
|
|
onclick={() =>
|
|
onoptionselect?.({
|
|
index: index,
|
|
value: option.value
|
|
})}
|
|
>
|
|
{option.label || option.value}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<Examples {examples} {placeholder} {latex_delimiters} {onexampleselect} />
|
|
{/if}
|
|
</div>
|
|
|
|
{#if show_scroll_button}
|
|
<div class="scroll-down-button-container">
|
|
<IconButton
|
|
Icon={ScrollDownArrow}
|
|
label="Scroll down"
|
|
size="large"
|
|
onclick={scroll_to_bottom}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.panel-wrap {
|
|
width: 100%;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.bubble-wrap {
|
|
width: 100%;
|
|
overflow-y: auto;
|
|
height: 100%;
|
|
padding-top: var(--spacing-xxl);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.bubble-wrap {
|
|
background: var(--background-fill-secondary);
|
|
}
|
|
}
|
|
|
|
.message-wrap :global(.prose.chatbot.md) {
|
|
opacity: 0.8;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.message-wrap :global(.message-row .md img) {
|
|
border-radius: var(--radius-xl);
|
|
margin: var(--size-2);
|
|
width: 400px;
|
|
max-width: 30vw;
|
|
max-height: 30vw;
|
|
}
|
|
|
|
/* link styles */
|
|
.message-wrap :global(.message a) {
|
|
color: var(--color-text-link);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* table styles */
|
|
.message-wrap :global(.bot:not(:has(.table-wrap)) table),
|
|
.message-wrap :global(.bot:not(:has(.table-wrap)) tr),
|
|
.message-wrap :global(.bot:not(:has(.table-wrap)) td),
|
|
.message-wrap :global(.bot:not(:has(.table-wrap)) th) {
|
|
border: 1px solid var(--border-color-primary);
|
|
}
|
|
|
|
.message-wrap :global(.user table),
|
|
.message-wrap :global(.user tr),
|
|
.message-wrap :global(.user td),
|
|
.message-wrap :global(.user th) {
|
|
border: 1px solid var(--border-color-accent);
|
|
}
|
|
|
|
/* KaTeX */
|
|
.message-wrap :global(span.katex) {
|
|
font-size: var(--text-lg);
|
|
direction: ltr;
|
|
}
|
|
|
|
.message-wrap :global(span.katex-display) {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.message-wrap :global(pre) {
|
|
position: relative;
|
|
}
|
|
|
|
.message-wrap :global(.grid-wrap) {
|
|
max-height: 80% !important;
|
|
max-width: 600px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.message-wrap > div :global(p:not(:first-child)) {
|
|
margin-top: var(--spacing-xxl);
|
|
}
|
|
|
|
.message-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
margin-bottom: var(--spacing-xxl);
|
|
direction: ltr;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.options {
|
|
margin-left: auto;
|
|
padding: var(--spacing-xxl);
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: var(--spacing-xxl);
|
|
max-width: calc(min(4 * 200px + 5 * var(--spacing-xxl), 100%));
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.option {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: var(--spacing-xl);
|
|
border: 1px dashed var(--border-color-primary);
|
|
border-radius: var(--radius-md);
|
|
background-color: var(--background-fill-secondary);
|
|
cursor: pointer;
|
|
transition: var(--button-transition);
|
|
max-width: var(--size-56);
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
|
|
.option:hover {
|
|
background-color: var(--color-accent-soft);
|
|
border-color: var(--border-color-accent);
|
|
}
|
|
</style>
|