diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b18d8f0..347268aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### Breaking changes + +* The `ui.Chat` and `ui.MarkdownStream` components are now imported from the new `shinychat` library. Future versions of `shinychat` will likely deprecate and remove some features from `Chat`. If you still want to use those features with the latest Shiny, we suggest pinning `shinychat` to it's initial release (v0.1.0). (#2051) + ### New features * Added `ui.insert_nav_panel()`, `ui.remove_nav_panel()`, and `ui.update_nav_panel()` to support dynamic navigation. (#90) diff --git a/docs/_quartodoc-express.yml b/docs/_quartodoc-express.yml index b16f09292..cda3a33dd 100644 --- a/docs/_quartodoc-express.yml +++ b/docs/_quartodoc-express.yml @@ -84,7 +84,8 @@ quartodoc: - title: Chat interface desc: Build a chatbot interface contents: - - express.ui.Chat + - name: express.ui.Chat + include_inherited: true - title: Streaming markdown desc: Stream markdown content into the UI contents: diff --git a/js/build.ts b/js/build.ts index 3a8825389..85ca3b1b8 100644 --- a/js/build.ts +++ b/js/build.ts @@ -102,26 +102,6 @@ const opts: Array = [ entryPoints: { "spin/spin": "spin/spin.scss" }, plugins: [sassPlugin({ type: "css", sourceMap: false })], }, - { - entryPoints: { - "markdown-stream/markdown-stream": "markdown-stream/markdown-stream.ts", - }, - }, - { - entryPoints: { - "markdown-stream/markdown-stream": "markdown-stream/markdown-stream.scss", - }, - plugins: [sassPlugin({ type: "css", sourceMap: false })], - }, - { - entryPoints: { - "chat/chat": "chat/chat.ts", - }, - }, - { - entryPoints: { "chat/chat": "chat/chat.scss" }, - plugins: [sassPlugin({ type: "css", sourceMap: false })], - }, ]; (async () => { diff --git a/js/chat/chat.scss b/js/chat/chat.scss deleted file mode 100644 index b5011f6da..000000000 --- a/js/chat/chat.scss +++ /dev/null @@ -1,186 +0,0 @@ -shiny-chat-container { - --shiny-chat-border: var(--bs-border-width, 1px) solid var(--bs-border-color, #e9ecef); - --shiny-chat-user-message-bg: RGBA(var(--bs-primary-rgb, 0, 123, 194), 0.06); - --_chat-container-padding: 0.25rem; - - display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr auto; - margin: 0 auto; - gap: 0; - padding: var(--_chat-container-padding); - padding-bottom: 0; // Bottom padding is on input element - - p:last-child { - margin-bottom: 0; - } - - .suggestion, - [data-suggestion] { - cursor: pointer; - } - - // Styling for inline-text - .suggestion { - 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-inline: 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 - } - } - } - - // Styling for card suggestions - .card[data-suggestion]:hover { - color: var(--bs-link-color, #007bc2); - border-color: rgba(var(--bs-link-color-rgb), 0.5); - } -} - -shiny-chat-messages { - display: flex; - flex-direction: column; - gap: 2rem; - overflow: auto; - margin-bottom: 1rem; - - // Make space for the scroll bar - --_scroll-margin: 1rem; - padding-right: var(--_scroll-margin); - margin-right: calc(-1 * var(--_scroll-margin)); -} - -shiny-chat-message { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 1rem; - > * { - height: fit-content; - } - .message-icon { - border-radius: 50%; - border: var(--shiny-chat-border); - height: 2rem; - width: 2rem; - display: grid; - place-items: center; - overflow: clip; - - > * { - // images and avatars are full-bleed - height: 100%; - width: 100%; - max-width: 100%; - max-height: 100%; - margin: 0 !important; - object-fit: contain; - } - - > svg, - > .icon, - > .fa, - > .bi { - // icons and svgs need some padding within the circle - max-height: 66%; - max-width: 66%; - } - - // Provide .border-0 as a way to opt-out of border container - &:has(> .border-0) { - border: none; - border-radius: unset; - overflow: unset; - } - } - - /* Vertically center the 2nd column (message content) */ - shiny-markdown-stream { - align-self: center; - } -} - -/* Align the user message to the right */ -shiny-user-message { - align-self: flex-end; - padding: 0.75rem 1rem; - border-radius: 10px; - background-color: var(--shiny-chat-user-message-bg); - max-width: 100%; -} - -shiny-user-message, -shiny-chat-message { - &[content_type="text"] { - white-space: pre; - overflow-x: auto; - } -} - -shiny-chat-input { - --_input-padding-top: 0; - --_input-padding-bottom: var(--_chat-container-padding, 0.25rem); - - margin-top: calc(-1 * var(--_input-padding-top)); - position: sticky; - bottom: calc(-1 * var(--_input-padding-bottom) + 4px); - // 4px: autoscroll adds 2px to height, this keeps input from wiggling when scrolling on top of chat - padding-block: var(--_input-padding-top) var(--_input-padding-bottom); - - textarea { - --bs-border-radius: 26px; - resize: none; - padding-right: 36px !important; - max-height: 175px; - &::placeholder { - color: var(--bs-gray-600, #707782) !important; - } - } - button { - position: absolute; - bottom: calc(6px + var(--_input-padding-bottom)); - right: 8px; - background-color: transparent; - color: var(--bs-primary, #007bc2); - transition: color 0.25s ease-in-out; - border: none; - padding: 0; - cursor: pointer; - line-height: 16px; - border-radius: 50%; - &:disabled { - cursor: not-allowed; - color: var(--bs-gray-500, #8d959e); - } - } -} - -/* - Disable the page-level pulse when the chat input is disabled - (i.e., when a response is being generated and brought into the chat) -*/ -.shiny-busy:has(shiny-chat-input[disabled])::after { - display: none; -} diff --git a/js/chat/chat.ts b/js/chat/chat.ts deleted file mode 100644 index 36faa0630..000000000 --- a/js/chat/chat.ts +++ /dev/null @@ -1,602 +0,0 @@ -import { LitElement, html } from "lit"; -import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; -import { property } from "lit/decorators.js"; - -import { - LightElement, - createElement, - renderDependencies, - showShinyClientMessage, -} from "../utils/_utils"; - -import type { HtmlDep } from "../utils/_utils"; - -type ContentType = "markdown" | "html" | "text"; - -type Message = { - content: string; - role: "user" | "assistant"; - chunk_type: "message_start" | "message_end" | null; - content_type: ContentType; - icon?: string; - operation: "append" | null; -}; - -type ShinyChatMessage = { - id: string; - handler: string; - // Message keys will create custom element attributes, but html_deps are handled - // separately - obj: (Message & { html_deps?: HtmlDep[] }) | null; -}; - -type UpdateUserInput = { - value?: string; - placeholder?: string; - submit?: false; - focus?: false; -}; - -// https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734 -declare global { - interface GlobalEventHandlersEventMap { - "shiny-chat-input-sent": CustomEvent; - "shiny-chat-append-message": CustomEvent; - "shiny-chat-append-message-chunk": CustomEvent; - "shiny-chat-clear-messages": CustomEvent; - "shiny-chat-update-user-input": CustomEvent; - "shiny-chat-remove-loading-message": CustomEvent; - } -} - -const CHAT_MESSAGE_TAG = "shiny-chat-message"; -const CHAT_USER_MESSAGE_TAG = "shiny-user-message"; -const CHAT_MESSAGES_TAG = "shiny-chat-messages"; -const CHAT_INPUT_TAG = "shiny-chat-input"; -const CHAT_CONTAINER_TAG = "shiny-chat-container"; - -const ICONS = { - robot: - '', - // https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg - dots_fade: - '', -}; - -class ChatMessage extends LightElement { - @property() content = "..."; - @property({ attribute: "content-type" }) contentType: ContentType = - "markdown"; - @property({ type: Boolean, reflect: true }) streaming = false; - @property() icon = ""; - - render() { - // Show dots until we have content - const isEmpty = this.content.trim().length === 0; - const icon = isEmpty ? ICONS.dots_fade : this.icon || ICONS.robot; - - return html` -
${unsafeHTML(icon)}
- - `; - } - - #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 { - @property() content = "..."; - - render() { - return html` - - `; - } -} - -class ChatMessages extends LightElement { - render() { - return html``; - } -} - -interface ChatInputSetInputOptions { - submit?: boolean; - focus?: boolean; -} - -class ChatInput extends LightElement { - @property() placeholder = "Enter a message..."; - // 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(); - } - - private _disabled = false; - inputVisibleObserver?: IntersectionObserver; - - connectedCallback(): void { - super.connectedCallback(); - - this.inputVisibleObserver = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) this.#updateHeight(); - }); - }); - - this.inputVisibleObserver.observe(this); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - this.inputVisibleObserver?.disconnect(); - this.inputVisibleObserver = undefined; - } - - 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; - } - - private get value(): string { - return this.textarea.value; - } - - private get valueIsEmpty(): boolean { - return this.value.trim().length === 0; - } - - private get button(): HTMLButtonElement { - return this.querySelector("button") as HTMLButtonElement; - } - - render() { - const icon = - ''; - - return html` - - - `; - } - - // Pressing enter sends the message (if not empty) - #onKeyDown(e: KeyboardEvent): void { - const isEnter = e.code === "Enter" && !e.shiftKey; - if (isEnter && !this.valueIsEmpty) { - e.preventDefault(); - this.#sendInput(); - } - } - - #onInput(): void { - this.#updateHeight(); - this.button.disabled = this.disabled - ? true - : this.value.trim().length === 0; - } - - // Determine whether the button should be enabled/disabled on first render - protected firstUpdated(): void { - this.#onInput(); - } - - #sendInput(focus = true): void { - if (this.valueIsEmpty) return; - if (this.disabled) return; - - window.Shiny.setInputValue!(this.id, this.value, { priority: "event" }); - - // Emit event so parent element knows to insert the message - const sentEvent = new CustomEvent("shiny-chat-input-sent", { - detail: { content: this.value, role: "user" }, - bubbles: true, - composed: true, - }); - this.dispatchEvent(sentEvent); - - this.setInputValue(""); - this.disabled = true; - - if (focus) this.textarea.focus(); - } - - #updateHeight(): void { - const el = this.textarea; - if (el.scrollHeight == 0) { - return; - } - el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; - } - - 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; - - // 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({ attribute: "icon-assistant" }) iconAssistant = ""; - inputSentinelObserver?: IntersectionObserver; - - private get input(): ChatInput { - return this.querySelector(CHAT_INPUT_TAG) as ChatInput; - } - - private get messages(): ChatMessages { - return this.querySelector(CHAT_MESSAGES_TAG) as ChatMessages; - } - - private get lastMessage(): ChatMessage | null { - const last = this.messages.lastElementChild; - return last ? (last as ChatMessage) : null; - } - - render() { - return html``; - } - - connectedCallback(): void { - super.connectedCallback(); - - // We use a sentinel element that we place just above the shiny-chat-input. When it - // moves off-screen we know that the text area input is now floating, add shadow. - let sentinel = this.querySelector("div"); - if (!sentinel) { - sentinel = createElement("div", { - style: "width: 100%; height: 0;", - }) as HTMLElement; - this.input.insertAdjacentElement("afterend", sentinel); - } - - this.inputSentinelObserver = new IntersectionObserver( - (entries) => { - const inputTextarea = this.input.querySelector("textarea"); - if (!inputTextarea) return; - const addShadow = entries[0]?.intersectionRatio === 0; - inputTextarea.classList.toggle("shadow", addShadow); - }, - { - threshold: [0, 1], - rootMargin: "0px", - } - ); - - this.inputSentinelObserver.observe(sentinel); - } - - firstUpdated(): void { - // Don't attach event listeners until child elements are rendered - if (!this.messages) return; - - this.addEventListener("shiny-chat-input-sent", this.#onInputSent); - this.addEventListener("shiny-chat-append-message", this.#onAppend); - this.addEventListener( - "shiny-chat-append-message-chunk", - this.#onAppendChunk - ); - this.addEventListener("shiny-chat-clear-messages", this.#onClear); - this.addEventListener( - "shiny-chat-update-user-input", - this.#onUpdateUserInput - ); - this.addEventListener( - "shiny-chat-remove-loading-message", - this.#onRemoveLoadingMessage - ); - this.addEventListener("click", this.#onInputSuggestionClick); - this.addEventListener("keydown", this.#onInputSuggestionKeydown); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - - this.inputSentinelObserver?.disconnect(); - this.inputSentinelObserver = undefined; - - this.removeEventListener("shiny-chat-input-sent", this.#onInputSent); - this.removeEventListener("shiny-chat-append-message", this.#onAppend); - this.removeEventListener( - "shiny-chat-append-message-chunk", - this.#onAppendChunk - ); - this.removeEventListener("shiny-chat-clear-messages", this.#onClear); - this.removeEventListener( - "shiny-chat-update-user-input", - this.#onUpdateUserInput - ); - this.removeEventListener( - "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 - #onInputSent(event: CustomEvent): void { - this.#appendMessage(event.detail); - this.#addLoadingMessage(); - } - - // Handle an append message event from server - #onAppend(event: CustomEvent): void { - this.#appendMessage(event.detail); - } - - #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; - - if (this.iconAssistant) { - message.icon = message.icon || this.iconAssistant; - } - - const msg = createElement(TAG_NAME, message); - this.messages.appendChild(msg); - - if (finalize) { - this.#finalizeMessage(); - } - } - - // Loading message is just an empty message - #addLoadingMessage(): void { - const loading_message = { - content: "", - role: "assistant", - }; - const message = createElement(CHAT_MESSAGE_TAG, loading_message); - this.messages.appendChild(message); - } - - #removeLoadingMessage(): void { - const content = this.lastMessage?.content; - if (!content) this.lastMessage?.remove(); - } - - #onAppendChunk(event: CustomEvent): void { - this.#appendMessageChunk(event.detail); - } - - #appendMessageChunk(message: Message): void { - if (message.chunk_type === "message_start") { - this.#appendMessage(message, false); - } - - const lastMessage = this.lastMessage; - if (!lastMessage) throw new Error("No messages found in the chat output"); - - if (message.chunk_type === "message_start") { - lastMessage.setAttribute("streaming", ""); - return; - } - - const content = - message.operation === "append" - ? lastMessage.getAttribute("content") + message.content - : message.content; - - lastMessage.setAttribute("content", content); - - if (message.chunk_type === "message_end") { - this.lastMessage?.removeAttribute("streaming"); - this.#finalizeMessage(); - } - } - - #onClear(): void { - this.messages.innerHTML = ""; - } - - #onUpdateUserInput(event: CustomEvent): void { - const { value, placeholder, submit, focus } = event.detail; - if (value !== undefined) { - 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(); - } - - #finalizeMessage(): void { - this.input.disabled = false; - } -} - -// ------- Register custom elements and shiny bindings --------- - -const chatCustomElements = [ - { tag: CHAT_MESSAGE_TAG, component: ChatMessage }, - { tag: CHAT_USER_MESSAGE_TAG, component: ChatUserMessage }, - { tag: CHAT_MESSAGES_TAG, component: ChatMessages }, - { tag: CHAT_INPUT_TAG, component: ChatInput }, - { tag: CHAT_CONTAINER_TAG, component: ChatContainer } -]; - -chatCustomElements.forEach(({ tag, component }) => { - if (!customElements.get(tag)) { - customElements.define(tag, component); - } -}); - -window.Shiny.addCustomMessageHandler( - "shinyChatMessage", - async function (message: ShinyChatMessage) { - if (message.obj?.html_deps) { - await renderDependencies(message.obj.html_deps); - } - - const evt = new CustomEvent(message.handler, { - detail: message.obj, - }); - - const el = document.getElementById(message.id); - - if (!el) { - showShinyClientMessage({ - status: "error", - message: `Unable to handle Chat() message since element with id - ${message.id} wasn't found. Do you need to call .ui() (Express) or need a - chat_ui('${message.id}') in the UI (Core)? - `, - }); - return; - } - - el.dispatchEvent(evt); - } -); - -export { CHAT_CONTAINER_TAG }; diff --git a/js/markdown-stream/highlight_styles.scss b/js/markdown-stream/highlight_styles.scss deleted file mode 100644 index 3c58b8861..000000000 --- a/js/markdown-stream/highlight_styles.scss +++ /dev/null @@ -1,193 +0,0 @@ -/************************************************************ - From ../node_modules/highlight.js/styles/atom-one-light.css - with minor adjustments -************************************************************/ -@mixin atom_one_light { - pre code.hljs { - display: block; - overflow-x: auto; - padding: 1em; - } - code.hljs { - padding: 3px 5px; - } - /* - - Atom One Light by Daniel Gamage - Original One Light Syntax theme from https://github.com/atom/one-light-syntax - - base: #fafafa - mono-1: #383a42 - mono-2: #686b77 - mono-3: #a0a1a7 - hue-1: #0184bb - hue-2: #4078f2 - hue-3: #a626a4 - hue-4: #50a14f - hue-5: #e45649 - hue-5-2: #c91243 - hue-6: #986801 - hue-6-2: #c18401 - - */ - pre:has(> code.hljs) { - color: #383a42; - background: #fafafa; - } - .hljs-comment, - .hljs-quote { - color: #a0a1a7; - font-style: italic; - } - .hljs-doctag, - .hljs-keyword, - .hljs-formula { - color: #a626a4; - } - .hljs-section, - .hljs-name, - .hljs-selector-tag, - .hljs-deletion, - .hljs-subst { - color: #e45649; - } - .hljs-literal { - color: #0184bb; - } - .hljs-string, - .hljs-regexp, - .hljs-addition, - .hljs-attribute, - .hljs-meta .hljs-string { - color: #50a14f; - } - .hljs-attr, - .hljs-variable, - .hljs-template-variable, - .hljs-type, - .hljs-selector-class, - .hljs-selector-attr, - .hljs-selector-pseudo, - .hljs-number { - color: #986801; - } - .hljs-symbol, - .hljs-bullet, - .hljs-link, - .hljs-meta, - .hljs-selector-id, - .hljs-title { - color: #4078f2; - } - .hljs-built_in, - .hljs-title.class_, - .hljs-class .hljs-title { - color: #c18401; - } - .hljs-emphasis { - font-style: italic; - } - .hljs-strong { - font-weight: bold; - } - .hljs-link { - text-decoration: underline; - } -} - -/************************************************************ - From ../node_modules/highlight.js/styles/atom-one-dark.css - with minor adjustments -************************************************************/ -@mixin atom_one_dark { - pre code.hljs { - display: block; - overflow-x: auto; - padding: 1em; - } - code.hljs { - padding: 3px 5px; - } - /* - - Atom One Dark by Daniel Gamage - Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax - - base: #282c34 - mono-1: #abb2bf - mono-2: #818896 - mono-3: #5c6370 - hue-1: #56b6c2 - hue-2: #61aeee - hue-3: #c678dd - hue-4: #98c379 - hue-5: #e06c75 - hue-5-2: #be5046 - hue-6: #d19a66 - hue-6-2: #e6c07b - - */ - pre:has(> code.hljs) { - color: #abb2bf; - background: #282c34; - } - .hljs-comment, - .hljs-quote { - color: #5c6370; - font-style: italic; - } - .hljs-doctag, - .hljs-keyword, - .hljs-formula { - color: #c678dd; - } - .hljs-section, - .hljs-name, - .hljs-selector-tag, - .hljs-deletion, - .hljs-subst { - color: #e06c75; - } - .hljs-literal { - color: #56b6c2; - } - .hljs-string, - .hljs-regexp, - .hljs-addition, - .hljs-attribute, - .hljs-meta .hljs-string { - color: #98c379; - } - .hljs-attr, - .hljs-variable, - .hljs-template-variable, - .hljs-type, - .hljs-selector-class, - .hljs-selector-attr, - .hljs-selector-pseudo, - .hljs-number { - color: #d19a66; - } - .hljs-symbol, - .hljs-bullet, - .hljs-link, - .hljs-meta, - .hljs-selector-id, - .hljs-title { - color: #61aeee; - } - .hljs-built_in, - .hljs-title.class_, - .hljs-class .hljs-title { - color: #e6c07b; - } - .hljs-emphasis { - font-style: italic; - } - .hljs-strong { - font-weight: bold; - } - .hljs-link { - text-decoration: underline; - } -} diff --git a/js/markdown-stream/markdown-stream.scss b/js/markdown-stream/markdown-stream.scss deleted file mode 100644 index 7d03469f6..000000000 --- a/js/markdown-stream/markdown-stream.scss +++ /dev/null @@ -1,79 +0,0 @@ -@use "highlight_styles" as highlight_styles; - -/* Code highlighting (for both light and dark mode) */ -@include highlight_styles.atom_one_light; -[data-bs-theme="dark"] { - @include highlight_styles.atom_one_dark; -} - -shiny-markdown-stream { - display: block; -} - -/* - Styling for the code-copy button (inspired by Quarto's code-copy feature) -*/ -pre:has(.code-copy-button) { - position: relative; -} - -.code-copy-button { - position: absolute; - top: 0; - right: 0; - border: 0; - margin-top: 5px; - margin-right: 5px; - background-color: transparent; - - > .bi { - display: flex; - gap: 0.25em; - - &::after { - content: ""; - display: block; - height: 1rem; - width: 1rem; - mask-image: url('data:image/svg+xml,'); - background-color: var(--bs-body-color, #222); - } - } -} - -.code-copy-button-checked { - > .bi::before { - content: "Copied!"; - font-size: 0.75em; - vertical-align: 0.25em; - } - - > .bi::after { - mask-image: url('data:image/svg+xml,'); - background-color: var(--bs-success, #198754); - } -} - -@keyframes markdown-stream-dot-pulse { - 0% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(0.4); - opacity: 0.4; - } - 100% { - transform: scale(1); - opacity: 1; - } -} - -.markdown-stream-dot { - // The stream dot is appended with each streaming chunk update, so the pulse animation - // only shows up when streaming pauses but isn't complete. - animation: markdown-stream-dot-pulse 1.75s infinite cubic-bezier(0.18, 0.89, 0.32, 1.28); - animation-delay: 250ms; - display: inline-block; - transform-origin: center; -} diff --git a/js/markdown-stream/markdown-stream.ts b/js/markdown-stream/markdown-stream.ts deleted file mode 100644 index 9d7f77ff5..000000000 --- a/js/markdown-stream/markdown-stream.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { PropertyValues, html } from "lit"; -import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; -import { property } from "lit/decorators.js"; - -import ClipboardJS from "clipboard"; -import hljs from "highlight.js/lib/common"; -import { Renderer, parse } from "marked"; - -import { CHAT_CONTAINER_TAG } from "../chat/chat"; - -import { - LightElement, - createElement, - createSVGIcon, - renderDependencies, - sanitizeHTML, - showShinyClientMessage, - throttle, -} from "../utils/_utils"; - -import type { HtmlDep } from "../utils/_utils"; - -type ContentType = "markdown" | "semi-markdown" | "html" | "text"; - -type ContentMessage = { - id: string; - content: string; - operation: "append" | "replace"; - html_deps?: HtmlDep[]; -}; - -type IsStreamingMessage = { - id: string; - isStreaming: boolean; -}; - -// Type guard -function isStreamingMessage( - message: ContentMessage | IsStreamingMessage -): message is IsStreamingMessage { - return "isStreaming" in message; -} - -// SVG dot to indicate content is currently streaming -const SVG_DOT_CLASS = "markdown-stream-dot"; -const SVG_DOT = createSVGIcon( - `` -); - -// 'markdown' renderer (for assistant messages) -const markdownRenderer = new Renderer(); - -// Add some basic Bootstrap styling to markdown tables -markdownRenderer.table = (header: string, body: string) => { - return ` - ${header} - ${body} -
`; -}; - -// 'semi-markdown' renderer (for user messages) -const semiMarkdownRenderer = new Renderer(); - -// Escape HTML, not for security reasons, but just because it's confusing if the user is -// using tag-like syntax to demarcate parts of their prompt for other reasons (like -// / for providing examples to the model), and those tags vanish. -semiMarkdownRenderer.html = (html: string) => - html - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - -function contentToHTML(content: string, content_type: ContentType) { - if (content_type === "markdown") { - const html = parse(content, { renderer: markdownRenderer }); - return unsafeHTML(sanitizeHTML(html as string)); - } else if (content_type === "semi-markdown") { - const html = parse(content, { renderer: semiMarkdownRenderer }); - return unsafeHTML(sanitizeHTML(html as string)); - } else if (content_type === "html") { - return unsafeHTML(sanitizeHTML(content)); - } else if (content_type === "text") { - return content; - } else { - throw new Error(`Unknown content type: ${content_type}`); - } -} - -class MarkdownElement extends LightElement { - @property() content = ""; - @property({ attribute: "content-type" }) - content_type: ContentType = "markdown"; - @property({ type: Boolean, reflect: true }) - 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)}`; - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - this.#cleanup(); - } - - protected willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has("content")) { - this.#isContentBeingAdded = true; - - MarkdownElement.#doUnBind(this); - } - super.willUpdate(changedProperties); - } - - protected updated(changedProperties: Map): void { - if (changedProperties.has("content")) { - // Post-process DOM after content has been added - try { - this.#highlightAndCodeCopy(); - } catch (error) { - console.warn("Failed to highlight code:", error); - } - - // Render Shiny HTML dependencies and bind inputs/outputs - if (this.streaming) { - this.#appendStreamingDot(); - MarkdownElement._throttledBind(this); - } else { - MarkdownElement.#doBind(this); - } - - // Update scrollable element after content has been added - this.#updateScrollableElement(); - - // 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")) { - if (this.streaming) { - this.#appendStreamingDot(); - } else { - this.#removeStreamingDot(); - if (this.onStreamEnd) { - try { - this.onStreamEnd(); - } catch (error) { - console.warn("Failed to call onStreamEnd callback:", error); - } - } - } - } - } - - #appendStreamingDot(): void { - this.lastElementChild?.appendChild(SVG_DOT); - } - - #removeStreamingDot(): void { - this.querySelector(`svg.${SVG_DOT_CLASS}`)?.remove(); - } - - static async #doUnBind(el: HTMLElement): Promise { - if (!window?.Shiny?.unbindAll) return; - - try { - window.Shiny.unbindAll(el); - } catch (err) { - showShinyClientMessage({ - status: "error", - message: `Failed to unbind Shiny inputs/outputs: ${err}`, - }); - } - } - - static async #doBind(el: HTMLElement): Promise { - if (!window?.Shiny?.initializeInputs) return; - if (!window?.Shiny?.bindAll) return; - - try { - window.Shiny.initializeInputs(el); - } catch (err) { - showShinyClientMessage({ - status: "error", - message: `Failed to initialize Shiny inputs: ${err}`, - }); - } - - try { - await window.Shiny.bindAll(el); - } catch (err) { - showShinyClientMessage({ - status: "error", - message: `Failed to bind Shiny inputs/outputs: ${err}`, - }); - } - } - - @throttle(200) - private static async _throttledBind(el: HTMLElement): Promise { - await this.#doBind(el); - } - - #highlightAndCodeCopy(): void { - const el = this.querySelector("pre code"); - if (!el) return; - this.querySelectorAll("pre code").forEach((el) => { - if (el.dataset.highlighted === "yes") return; - - hljs.highlightElement(el); - - // Add copy button - const btn = createElement("button", { - class: "code-copy-button", - title: "Copy to clipboard", - }); - btn.innerHTML = ''; - el.prepend(btn); - - // Setup clipboard - const clipboard = new ClipboardJS(btn, { target: () => el }); - clipboard.on("success", (e) => { - btn.classList.add("code-copy-button-checked"); - setTimeout( - () => btn.classList.remove("code-copy-button-checked"), - 2000 - ); - e.clearSelection(); - }); - }); - } - - // ------- Scrolling logic ------- - - // Nearest scrollable parent element (if any) - #scrollableElement: HTMLElement | null = null; - // Whether content is currently being added to the element - #isContentBeingAdded = false; - // Whether the user has scrolled away from the bottom - #isUserScrolled = false; - - #onScroll = (): void => { - if (!this.#isContentBeingAdded) { - this.#isUserScrolled = !this.#isNearBottom(); - } - }; - - #isNearBottom(): boolean { - const el = this.#scrollableElement; - if (!el) return false; - - return el.scrollHeight - (el.scrollTop + el.clientHeight) < 50; - } - - #updateScrollableElement(): void { - const el = this.#findScrollableParent(); - - if (el !== this.#scrollableElement) { - this.#scrollableElement?.removeEventListener("scroll", this.#onScroll); - this.#scrollableElement = el; - this.#scrollableElement?.addEventListener("scroll", this.#onScroll); - } - } - - #findScrollableParent(): HTMLElement | null { - if (!this.auto_scroll) return null; - - // eslint-disable-next-line - let el: HTMLElement | null = this; - while (el) { - if (el.scrollHeight > el.clientHeight) return el; - el = el.parentElement; - if (el?.tagName?.toLowerCase() === CHAT_CONTAINER_TAG.toLowerCase()) { - // This ensures that we do not accidentally scroll a parent element of the chat - // container. If the chat container itself is scrollable, a scrollable element - // would already have been identified. - break; - } - } - return null; - } - - #maybeScrollToBottom(): void { - const el = this.#scrollableElement; - if (!el || this.#isUserScrolled) return; - - el.scroll({ - top: el.scrollHeight - el.clientHeight, - behavior: this.streaming ? "instant" : "smooth", - }); - } - - #cleanup(): void { - this.#scrollableElement?.removeEventListener("scroll", this.#onScroll); - this.#scrollableElement = null; - this.#isUserScrolled = false; - } -} - -// ------- Register custom elements and shiny bindings --------- - -if (!customElements.get("shiny-markdown-stream")) { - customElements.define("shiny-markdown-stream", MarkdownElement); -} - -async function handleMessage( - message: ContentMessage | IsStreamingMessage -): Promise { - const el = document.getElementById(message.id) as MarkdownElement; - - if (!el) { - showShinyClientMessage({ - status: "error", - message: `Unable to handle MarkdownStream() message since element with id - ${message.id} wasn't found. Do you need to call .ui() (Express) or need a - output_markdown_stream('${message.id}') in the UI (Core)?`, - }); - return; - } - - if (isStreamingMessage(message)) { - el.streaming = message.isStreaming; - return; - } - - if (message.html_deps) { - await renderDependencies(message.html_deps); - } - - if (message.operation === "replace") { - el.setAttribute("content", message.content); - } else if (message.operation === "append") { - const content = el.getAttribute("content"); - el.setAttribute("content", content + message.content); - } else { - throw new Error(`Unknown operation: ${message.operation}`); - } -} - -window.Shiny.addCustomMessageHandler( - "shinyMarkdownStreamMessage", - handleMessage -); - -export { MarkdownElement, contentToHTML }; diff --git a/pyproject.toml b/pyproject.toml index 307d230e1..176839984 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "setuptools;python_version>='3.12'", "narwhals>=1.10.0", "orjson>=3.10.7", + "shinychat>=0.1.0", ] [project.optional-dependencies] @@ -110,14 +111,7 @@ dev = [ "numpy", "shinyswatch>=0.7.0", "python-dotenv", - "anthropic", - "google-generativeai;python_version>='3.9'", - "langchain_core", - "langsmith>=0.3.4", - "openai", - "ollama", "chatlas>=0.6.1", - "tokenizers", "aiohttp", "beautifulsoup4", ] diff --git a/shiny/_docstring.py b/shiny/_docstring.py index f6d41c447..92f1c8fa7 100644 --- a/shiny/_docstring.py +++ b/shiny/_docstring.py @@ -295,7 +295,11 @@ def app_choose_core_or_express( def get_decorated_source_directory(func: FuncType) -> str: if hasattr(func, "__module__"): - path = os.path.abspath(str(sys.modules[func.__module__].__file__)) + m = func.__module__ + # If function/object is defined in shiny, we use the module's file path. + # Otherwise, we use the file path of the main shiny module. + m2 = m if m.startswith("shiny.") else "shiny" + path = os.path.abspath(str(sys.modules[m2].__file__)) else: path = os.path.abspath(func.__code__.co_filename) diff --git a/shiny/playwright/controller/__init__.py b/shiny/playwright/controller/__init__.py index 05d529e69..058da7f5b 100644 --- a/shiny/playwright/controller/__init__.py +++ b/shiny/playwright/controller/__init__.py @@ -1,3 +1,14 @@ +from shinychat.playwright import ChatController as Chat + +from ._accordion import ( + Accordion, + AccordionPanel, +) +from ._card import Card, ValueBox +from ._file import ( + DownloadButton, + DownloadLink, +) from ._input_buttons import ( InputActionButton, InputActionLink, @@ -6,16 +17,6 @@ InputFile, InputTaskButton, ) - -from ._input_fields import ( - InputDate, - InputDateRange, - InputNumeric, - InputPassword, - InputText, - InputTextArea, -) - from ._input_controls import ( InputCheckbox, InputCheckboxGroup, @@ -26,31 +27,17 @@ InputSliderRange, InputSwitch, ) - -from ._overlay import ( - Popover, - Tooltip, +from ._input_fields import ( + InputDate, + InputDateRange, + InputNumeric, + InputPassword, + InputText, + InputTextArea, ) - from ._layout import ( Sidebar, ) - -from ._accordion import ( - Accordion, - AccordionPanel, -) - -from ._card import Card, ValueBox - -from ._file import ( - DownloadButton, - DownloadLink, -) - -from ._chat import ( - Chat, -) from ._navs import ( NavPanel, NavsetBar, @@ -74,6 +61,10 @@ OutputTextVerbatim, OutputUi, ) +from ._overlay import ( + Popover, + Tooltip, +) __all__ = [ "InputActionButton", diff --git a/shiny/playwright/controller/_chat.py b/shiny/playwright/controller/_chat.py deleted file mode 100644 index edcfcaa79..000000000 --- a/shiny/playwright/controller/_chat.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from playwright.sync_api import Locator, Page -from playwright.sync_api import expect as playwright_expect - -from .._types import PatternOrStr, Timeout -from ._base import UiBase - - -class Chat(UiBase): - """Controller for :func:`shiny.ui.chat`.""" - - loc: Locator - """ - Playwright `Locator` for the chat. - """ - loc_messages: Locator - """ - Playwright `Locator` for the chat messages. - """ - loc_latest_message: Locator - """ - Playwright `Locator` for the last message in the chat. - """ - loc_input_container: Locator - """ - Playwright `Locator` for the chat input container. - """ - loc_input: Locator - """ - Playwright `Locator` for the chat's