<script lang="ts"> import { format_chat_for_sharing } from "./utils"; import { copy } from "@gradio/utils"; import { dequal } from "dequal/lite"; import { beforeUpdate, afterUpdate, createEventDispatcher } from "svelte"; import { ShareButton } from "@gradio/atoms"; import { Audio } from "@gradio/audio/shared"; import { Image } from "@gradio/image/shared"; import { Video } from "@gradio/video/shared"; import type { SelectData, LikeData } from "@gradio/utils"; import type { ChatMessage, ChatFileMessage, Message, MessageRole } from "../types"; import { MarkdownCode as Markdown } from "@gradio/markdown"; import { FileData } from "@gradio/client"; import Copy from "./Copy.svelte"; import type { I18nFormatter } from "js/app/src/gradio_helper"; import LikeDislike from "./LikeDislike.svelte"; import Pending from "./Pending.svelte"; import ToolMessage from "./ToolMessage.svelte"; import ErrorMessage from "./ErrorMessage.svelte"; export let value: (ChatMessage | ChatFileMessage)[] = []; let old_value: (ChatMessage | ChatFileMessage)[] | null = null; export let latex_delimiters: { left: string; right: string; display: boolean; }[]; export let pending_message = false; export let selectable = false; export let likeable = false; export let show_share_button = false; export let rtl = false; export let show_copy_button = false; export let avatar_images: [FileData | null, FileData | null] = [null, null]; export let sanitize_html = true; export let bubble_full_width = true; export let render_markdown = true; export let line_breaks = true; export let i18n: I18nFormatter; export let layout: "bubble" | "panel" = "bubble"; export let placeholder: string | null = null; let div: HTMLDivElement; let autoscroll: boolean; $: adjust_text_size = () => { let style = getComputedStyle(document.body); let body_text_size = style.getPropertyValue("--body-text-size"); let updated_text_size; switch (body_text_size) { case "13px": updated_text_size = 14; break; case "14px": updated_text_size = 16; break; case "16px": updated_text_size = 20; break; default: updated_text_size = 14; break; } document.body.style.setProperty( "--chatbot-body-text-size", updated_text_size + "px" ); }; $: adjust_text_size(); const dispatch = createEventDispatcher<{ change: undefined; select: SelectData; like: LikeData; }>(); beforeUpdate(() => { autoscroll = div && div.offsetHeight + div.scrollTop > div.scrollHeight - 100; }); const scroll = (): void => { if (autoscroll) { div.scrollTo(0, div.scrollHeight); } }; afterUpdate(() => { if (autoscroll) { scroll(); div.querySelectorAll("img").forEach((n) => { n.addEventListener("load", () => { scroll(); }); }); } }); $: { if (!dequal(value, old_value)) { old_value = value; dispatch("change"); } } function handle_select( i: number, message: Message ): void { dispatch("select", { index: i, value: (message as ChatMessage).content || (message as ChatFileMessage).file?.url }); } function handle_like( i: number, message: Message | null, selected: string | null ): void { dispatch("like", { index: i, value: (message as ChatMessage).content || (message as ChatFileMessage).file?.url, liked: selected === "like" }); } function isFileMessage( message: ChatMessage | ChatFileMessage ): message is ChatFileMessage { return "file" in message; } function groupMessages(messages: (ChatMessage | ChatFileMessage)[]): (ChatMessage | ChatFileMessage)[][] { const groupedMessages: (ChatMessage | ChatFileMessage)[][] = []; let currentGroup: (ChatMessage | ChatFileMessage)[] = []; let currentRole: MessageRole | null = null; for (const message of messages) { if (message.role === currentRole) { currentGroup.push(message); } else { if (currentGroup.length > 0) { groupedMessages.push(currentGroup); } currentGroup = [message]; currentRole = message.role; } } if (currentGroup.length > 0) { groupedMessages.push(currentGroup); } return groupedMessages; } </script> {#if show_share_button && value !== null && value.length > 0} <div class="share-button"> <ShareButton {i18n} on:error on:share formatter={format_chat_for_sharing} {value} /> </div> {/if} <div class={layout === "bubble" ? "bubble-wrap" : "panel-wrap"} class:placeholder-container={value === null || value.length === 0} bind:this={div} role="log" aria-label="chatbot conversation" aria-live="polite" > <div class="message-wrap" class:bubble-gap={layout === "bubble"} use:copy> {#if value !== null && value.length > 0} {@const groupedMessages = groupMessages(value)} {#each groupedMessages as messages, i} {#if messages.length} {@const role = messages[0].role === "user" ? 'user' : 'bot'} {@const avatar_img = avatar_images[role === "user" ? 0 : 1]} <div class="message-row {layout} {role === "user" ? 'user-row' : 'bot-row'}"> {#if avatar_img} <div class="avatar-container"> <Image class="avatar-image" src={avatar_img.url} alt="{role} avatar" /> </div> {/if} <div class="message {role === "user" ? 'user' : 'bot'}" class:message-fit={layout === "bubble" && !bubble_full_width} class:panel-full-width={layout === "panel"} class:message-bubble-border={layout === "bubble"} class:message-markdown-disabled={!render_markdown} style:text-align={rtl && role == 'bot' ? "left" : "right"} > <button data-testid={role} class:latest={i === groupedMessages.length - 1} class:message-markdown-disabled={!render_markdown} style:user-select="text" class:selectable style:text-align={rtl ? "right" : "left"} on:click={() => handle_select(i, messages[0])} on:keydown={(e) => { if (e.key === "Enter") { handle_select(i, messages[0]); } }} dir={rtl ? "rtl" : "ltr"} > {#each messages as message, thought_index} {#if !isFileMessage(message)} <div class:thought={thought_index > 0}> {#if message.thought_metadata.tool_name} <ToolMessage title={`Used tool ${message.thought_metadata.tool_name}`} > <!-- {message.content} --> <Markdown message={message.content} {latex_delimiters} {sanitize_html} {render_markdown} {line_breaks} on:load={scroll} /> </ToolMessage> {:else if message.thought_metadata.error} <ErrorMessage > <!-- {message.content} --> <Markdown message={message.content} {latex_delimiters} {sanitize_html} {render_markdown} {line_breaks} on:load={scroll} /> </ErrorMessage> {:else} <!-- {message.content} --> <Markdown message={message.content} {latex_delimiters} {sanitize_html} {render_markdown} {line_breaks} on:load={scroll} /> {/if} </div> {:else} {#if message.file.mime_type?.includes("audio")} <Audio data-testid="chatbot-audio" controls preload="metadata" src={message.file?.url} title={message.alt_text} on:play on:pause on:ended /> {:else if message !== null && message.file?.mime_type?.includes("video")} <Video data-testid="chatbot-video" controls src={message.file?.url} title={message.alt_text} preload="auto" on:play on:pause on:ended > <track kind="captions" /> </Video> {:else if message !== null && message.file?.mime_type?.includes("image")} <Image data-testid="chatbot-image" src={message.file?.url} alt={message.alt_text} /> {:else if message !== null && message.file?.url !== null} <a data-testid="chatbot-file" href={message.file?.url} target="_blank" download={window.__is_colab__ ? null : message.file?.orig_name || message.file?.path} > {message.file?.orig_name || message.file?.path} </a> {/if} {/if} {/each} </button> </div> <!-- {#if (likeable && role === 'bot') || (show_copy_button && message && typeof message === "string")} <div class="message-buttons-{role} message-buttons-{layout} {avatar_images[j] !== null && 'with-avatar'}" class:message-buttons-fit={layout === "bubble" && !bubble_full_width} class:bubble-buttons-user={layout === "bubble"} > {#if likeable && role === 'bot'} <LikeDislike handle_action={(selected) => handle_like(i, message, selected)} /> {/if} {#if show_copy_button && message && typeof message === "string"} <Copy value={message} /> {/if} </div> {/if} --> </div> {/if} {/each} {#if pending_message} <Pending {layout} /> {/if} {:else if placeholder !== null} <center> <Markdown message={placeholder} {latex_delimiters} /> </center> {/if} </div> </div> <style> .placeholder-container { display: flex; justify-content: center; align-items: center; height: 100%; } .bubble-wrap { padding: var(--block-padding); width: 100%; overflow-y: auto; } .panel-wrap { width: 100%; overflow-y: auto; } .message-wrap { display: flex; flex-direction: column; justify-content: space-between; } .bubble-gap { gap: calc(var(--spacing-xxl) + var(--spacing-lg)); } .message-wrap > div :not(.avatar-container) :global(img) { border-radius: 13px; margin: var(--size-2); width: 400px; max-width: 30vw; max-height: auto; } .message-wrap > div :global(p:not(:first-child)) { margin-top: var(--spacing-xxl); } .message { position: relative; display: flex; flex-direction: column; align-self: flex-end; background: var(--background-fill-secondary); width: calc(100% - var(--spacing-xxl)); color: var(--body-text-color); font-size: var(--chatbot-body-text-size); overflow-wrap: break-word; overflow-x: hidden; padding-right: calc(var(--spacing-xxl) + var(--spacing-md)); padding: calc(var(--spacing-xxl) + var(--spacing-sm)); } .thought { margin-top: var(--spacing-xxl); } .message :global(.prose) { font-size: var(--chatbot-body-text-size); } .message-bubble-border { border-width: 1px; border-radius: var(--radius-xxl); } .message-fit { width: fit-content !important; } .panel-full-width { padding: calc(var(--spacing-xxl) * 2); width: 100%; } .message-markdown-disabled { white-space: pre-line; } @media (max-width: 480px) { .panel-full-width { padding: calc(var(--spacing-xxl) * 2); } } .user { align-self: flex-start; border-bottom-right-radius: 0; text-align: right; } .bot { border-bottom-left-radius: 0; text-align: left; } /* Colors */ .bot { border-color: var(--border-color-primary); background: var(--background-fill-secondary); } .user { border-color: var(--border-color-accent-subdued); background-color: var(--color-accent-soft); } .message-row { display: flex; flex-direction: row; position: relative; } .message-row.panel.user-row { background: var(--color-accent-soft); } .message-row.panel.bot-row { background: var(--background-fill-secondary); } .message-row:last-of-type { margin-bottom: var(--spacing-xxl); } .user-row.bubble { flex-direction: row; justify-content: flex-end; } @media (max-width: 480px) { .user-row.bubble { align-self: flex-end; } .bot-row.bubble { align-self: flex-start; } .message { width: auto; } } .avatar-container { align-self: flex-end; position: relative; justify-content: center; width: 35px; height: 35px; flex-shrink: 0; bottom: 0; } .user-row.bubble > .avatar-container { order: 2; margin-left: 10px; } .bot-row.bubble > .avatar-container { margin-right: 10px; } .panel > .avatar-container { margin-left: 25px; align-self: center; } .avatar-container :global(img) { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } .message-buttons-user, .message-buttons-bot { border-radius: var(--radius-md); display: flex; align-items: center; bottom: 0; height: var(--size-7); align-self: self-end; position: absolute; bottom: -15px; margin: 2px; padding-left: 5px; z-index: 1; } .message-buttons-bot { left: 10px; } .message-buttons-user { right: 5px; } .message-buttons-bot.message-buttons-bubble.with-avatar { left: 50px; } .message-buttons-user.message-buttons-bubble.with-avatar { right: 50px; } .message-buttons-bubble { border: 1px solid var(--border-color-accent); background: var(--background-fill-secondary); } .message-buttons-panel { left: unset; right: 0px; top: 0px; } .share-button { position: absolute; top: 4px; right: 6px; } .selectable { cursor: pointer; } @keyframes dot-flashing { 0% { opacity: 0.8; } 50% { opacity: 0.5; } 100% { opacity: 0.8; } } .message-wrap .message :global(a) { color: var(--color-text-link); text-decoration: underline; } .message-wrap .bot :global(table), .message-wrap .bot :global(tr), .message-wrap .bot :global(td), .message-wrap .bot :global(th) { border: 1px solid var(--border-color-primary); } .message-wrap .user :global(table), .message-wrap .user :global(tr), .message-wrap .user :global(td), .message-wrap .user :global(th) { border: 1px solid var(--border-color-accent); } /* Lists */ .message-wrap :global(ol), .message-wrap :global(ul) { padding-inline-start: 2em; } /* KaTeX */ .message-wrap :global(span.katex) { font-size: var(--text-lg); direction: ltr; } /* Copy button */ .message-wrap :global(div[class*="code_wrap"] > button) { position: absolute; top: var(--spacing-md); right: var(--spacing-md); z-index: 1; cursor: pointer; border-bottom-left-radius: var(--radius-sm); padding: 5px; padding: var(--spacing-md); width: 25px; height: 25px; } .message-wrap :global(code > button > span) { position: absolute; top: var(--spacing-md); right: var(--spacing-md); width: 12px; height: 12px; } .message-wrap :global(.check) { position: absolute; top: 0; right: 0; opacity: 0; z-index: var(--layer-top); transition: opacity 0.2s; background: var(--background-fill-primary); padding: var(--size-1); width: 100%; height: 100%; color: var(--body-text-color); } .message-wrap :global(pre) { position: relative; } </style>