Spaces:
Running
Running
| /* eslint-disable */ | |
| /** | |
| * Gradio app bootstrap. | |
| * | |
| * The Python side injects window.HY_EXAMPLES / window.HY_I18N before this | |
| * script runs. The custom chat renderer (static/chat.js) owns all DOM | |
| * mutations under #hy-chat — this file only deals with the surrounding | |
| * UX (overlay, scroll, send-button state, copy/preview buttons). | |
| * | |
| * Responsibilities | |
| * ──────────────── | |
| * 1. Render the empty-state "What can I help you with?" overlay above the | |
| * chat surface, with example prompts that auto-send when clicked. | |
| * 2. Add a "▶ html preview" button below every Markdown HTML code block, | |
| * and render the code in a sandboxed iframe modal. | |
| * 3. Add a "Copy" button to the top-right of every <pre> code block. | |
| * 4. Own scroll behaviour: follow-the-tail while streaming, full user | |
| * control after streaming ends. | |
| * 5. OWN the Send-button busy state. The server NEVER toggles the | |
| * button. Instead, the streaming generators in chat.py drive a | |
| * dedicated hidden ``gr.HTML`` component (``#hy-busy-marker``): | |
| * while streaming → it contains a single | |
| * ``<span data-hy-streaming="1">``; on the final yield → it's | |
| * idle. We observe that one component and disable Send iff the | |
| * marker span is present. | |
| * | |
| * The marker lives in its OWN component (not inside the chat) so | |
| * unclosed ``<style>``/``<script>``/``<textarea>``/``<title>``/ | |
| * ``<noscript>`` in model output cannot hijack it. | |
| */ | |
| (() => { | |
| if (window.__HY_APP_INIT) return; | |
| window.__HY_APP_INIT = true; | |
| const EXAMPLES = (window.HY_EXAMPLES || []).slice(); | |
| const BUSY_MSG = | |
| (window.HY_I18N && window.HY_I18N.model_busy) || | |
| "Model is still responding. Please wait for it to finish."; | |
| // The chat surface lives at #hy-chat (built by static/chat.js into the | |
| // value of the gr.HTML at #hy-chat-host). Throughout this file we treat | |
| // #hy-chat as "the chatbot" — it's our custom replacement. | |
| const CHAT_SELECTOR = "#hy-chat"; | |
| // CSS selector for the in-flight streaming marker. See chat.py | |
| // (HY_BUSY_HTML) for the producer side. | |
| const BUSY_MARKER_HOST_ID = "hy-busy-marker"; | |
| const STREAM_MARKER_SELECTOR = | |
| '#' + BUSY_MARKER_HOST_ID + ' [data-hy-streaming="1"]'; | |
| // Explicit "server says idle" marker. Distinct from "no marker yet" | |
| // (the host's initial empty value), so we can tell apart "still | |
| // waiting for the first server frame" from "server already responded | |
| // with idle" (e.g. validation short-circuit before any streaming). | |
| const IDLE_MARKER_SELECTOR = | |
| '#' + BUSY_MARKER_HOST_ID + ' [data-hy-busy="0"]'; | |
| // Set to ``performance.now()`` whenever the user clicks Send / hits Enter. | |
| // Bridges the brief window between "user dispatched" and "first marker | |
| // frame arrived" so a fast double-click can't sneak through. | |
| let lastSendAt = 0; | |
| const SEND_DEBOUNCE_MS = 400; | |
| // Set to true the moment the user dispatches a send, cleared the moment | |
| // either (a) the server's first busy-marker frame actually arrives, or | |
| // (b) FIRST_FRAME_TIMEOUT_MS elapses (defensive timeout in case the | |
| // request never lands — e.g. WebSocket dropped before the first frame). | |
| // | |
| // SEND_DEBOUNCE_MS alone is NOT enough: on a slow link the round-trip | |
| // for the first frame can easily exceed 400ms and a second click slips | |
| // through, causing two parallel streams (server-side concurrency_limit | |
| // is 8 for the chat fn so the second request DOES run). | |
| let awaitingFirstFrame = false; | |
| const FIRST_FRAME_TIMEOUT_MS = 30 * 1000; | |
| // Soft watchdog: if the busy marker is set but the chat DOM has been | |
| // observably idle for this long AND it's been at least | |
| // CHAT_IDLE_MIN_SINCE_SEND_MS since the user clicked Send, we force- | |
| // clear the marker. Safety net for "the server's IDLE marker frame | |
| // got coalesced/dropped on the wire". | |
| // | |
| // CHAT_IDLE_MS MUST be comfortably greater than the server-side | |
| // ``_HEARTBEAT_INTERVAL`` (5s in core/chat.py). The watchdog is reset | |
| // on every applied delta — including heartbeats with empty ops — so | |
| // a healthy stream with periodic heartbeats keeps the timer well | |
| // below the threshold. The margin guards against jitter in heartbeat | |
| // arrival (proxy buffering, two-window contention slowing the | |
| // server's first-content yield, etc.) so the watchdog only fires | |
| // when the stream is actually dead. | |
| const CHAT_IDLE_MS = 12000; | |
| const CHAT_IDLE_MIN_SINCE_SEND_MS = 1500; | |
| // Hard absolute cap. Defence-in-depth backstop in case the soft | |
| // watchdog also fails for some reason. | |
| const ABSOLUTE_BUSY_TIMEOUT_MS = 5 * 60 * 1000; | |
| // Flipped to true on a real send dispatch so the scroll lock knows to | |
| // re-enter follow mode for the next streaming turn even if the user had | |
| // scrolled up during the previous turn. | |
| let justSentNewMessage = false; | |
| /* ── Chat mutation hub (rAF-batched) ─────────────────────────────────── | |
| * | |
| * The chat DOM gets HAMMERED during streaming: every new token may | |
| * mutate the trailing streaming segment. With each subsystem | |
| * (scroll lock, code-block injector, busy-marker timestamp, | |
| * empty-state visibility) installing its OWN MutationObserver, | |
| * the per-mutation work multiplied by the number of subsystems | |
| * was the dominant browser-side cost as conversations grew. | |
| * | |
| * We run a single MutationObserver and dispatch to subscribers via | |
| * requestAnimationFrame. This caps total dispatches at ≤60/sec | |
| * regardless of how many mutations the chat fires, and lets each | |
| * subscriber see the WHOLE batch of mutations in one call instead | |
| * of being woken N times in the same tick. | |
| * | |
| * Subscribers MUST be cheap — the rAF callback is on the critical | |
| * path of the next paint. */ | |
| const chatSubscribers = []; | |
| let chatPending = null; | |
| let chatScheduled = false; | |
| function subscribeChatMutations(cb) { | |
| chatSubscribers.push(cb); | |
| } | |
| function startChatMutationHub(chatEl) { | |
| new MutationObserver((mutations) => { | |
| if (chatPending === null) chatPending = []; | |
| // Ref-share the records — they're owned by the observer and we | |
| // don't mutate them, just iterate. | |
| for (let i = 0; i < mutations.length; i++) { | |
| chatPending.push(mutations[i]); | |
| } | |
| if (chatScheduled) return; | |
| chatScheduled = true; | |
| requestAnimationFrame(() => { | |
| chatScheduled = false; | |
| const batch = chatPending; | |
| chatPending = null; | |
| for (let i = 0; i < chatSubscribers.length; i++) { | |
| try { | |
| chatSubscribers[i](batch); | |
| } catch (err) { | |
| console.error("[hy] chat subscriber error:", err); | |
| } | |
| } | |
| }); | |
| }).observe(chatEl, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| }); | |
| } | |
| /* ── Empty-state examples overlay ─────────────────────────────────────── */ | |
| function createOverlay() { | |
| const chatEl = document.querySelector(CHAT_SELECTOR); | |
| if (!chatEl) { | |
| setTimeout(createOverlay, 500); | |
| return; | |
| } | |
| if (document.getElementById("examples-overlay")) return; | |
| const overlay = document.createElement("div"); | |
| overlay.id = "examples-overlay"; | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "examples-wrapper"; | |
| const heading = document.createElement("p"); | |
| heading.className = "examples-heading"; | |
| heading.textContent = | |
| (window.HY_I18N && window.HY_I18N.examples_heading) || | |
| "What can I help you with?"; | |
| wrapper.appendChild(heading); | |
| const grid = document.createElement("div"); | |
| grid.className = "examples-grid"; | |
| // Example-button label. | |
| // * Collapse internal whitespace/newlines to single spaces so each | |
| // card flows as one paragraph; the CSS 2-line clamp on | |
| // .example-btn handles the visual cut-off + ellipsis. | |
| // * Hard cap at LABEL_MAX_CHARS as defence-in-depth — the prompts | |
| // can be multi-kilobyte system instructions and we don't want | |
| // all of that in the DOM just to be invisibly clamped. | |
| // * ``title`` shows the full text on hover. | |
| // The FULL prompt is always what we dispatch to the model; this is | |
| // a display-only transform. | |
| const LABEL_MAX_CHARS = 200; | |
| function makeLabel(text) { | |
| const oneLine = String(text).replace(/\s+/g, " ").trim(); | |
| if (oneLine.length <= LABEL_MAX_CHARS) return oneLine; | |
| return oneLine.slice(0, LABEL_MAX_CHARS).trimEnd() + "…"; | |
| } | |
| EXAMPLES.forEach((text) => { | |
| // Two-layer structure: | |
| // .example-btn → flex container, owns size + centering | |
| // .example-btn-text → -webkit-box, owns the 2-line clamp | |
| // Splitting these is the only reliable way to get BOTH "vertical | |
| // centering" (needs display:flex) AND "clamp to 2 lines with auto | |
| // ellipsis" (needs display:-webkit-box) on the same card. Single- | |
| // element setups force a choice and we always lost one. | |
| const btn = document.createElement("div"); | |
| btn.className = "example-btn"; | |
| const inner = document.createElement("span"); | |
| inner.className = "example-btn-text"; | |
| inner.textContent = makeLabel(text); | |
| btn.appendChild(inner); | |
| btn.title = text; | |
| btn.addEventListener("click", () => { | |
| const textarea = document.querySelector(".msg-input textarea"); | |
| if (!textarea) return; | |
| textarea.focus(); | |
| textarea.select(); | |
| document.execCommand("insertText", false, text); | |
| setTimeout(() => { | |
| const sendBtn = document.querySelector("#send-btn"); | |
| if (sendBtn) sendBtn.click(); | |
| }, 200); | |
| }); | |
| grid.appendChild(btn); | |
| }); | |
| wrapper.appendChild(grid); | |
| overlay.appendChild(wrapper); | |
| function positionOverlay() { | |
| const r = chatEl.getBoundingClientRect(); | |
| overlay.style.top = `${r.top}px`; | |
| overlay.style.left = `${r.left}px`; | |
| overlay.style.width = `${r.width}px`; | |
| overlay.style.height = `${r.height}px`; | |
| } | |
| positionOverlay(); | |
| document.body.appendChild(overlay); | |
| function syncVisibility() { | |
| const msgs = chatEl.querySelectorAll(".message-row"); | |
| overlay.style.display = msgs.length > 0 ? "none" : ""; | |
| } | |
| syncVisibility(); | |
| subscribeChatMutations(syncVisibility); | |
| window.addEventListener("resize", positionOverlay); | |
| new ResizeObserver(positionOverlay).observe(chatEl); | |
| } | |
| /* ── HTML preview modal ───────────────────────────────────────────────── */ | |
| function openHtmlPreview(htmlCode) { | |
| const existing = document.querySelector(".html-preview-overlay"); | |
| if (existing) existing.remove(); | |
| const overlay = document.createElement("div"); | |
| overlay.className = "html-preview-overlay"; | |
| overlay.addEventListener("click", (e) => { | |
| if (e.target === overlay) overlay.remove(); | |
| }); | |
| const modal = document.createElement("div"); | |
| modal.className = "html-preview-modal"; | |
| const header = document.createElement("div"); | |
| header.className = "html-preview-header"; | |
| const title = document.createElement("span"); | |
| title.className = "html-preview-title"; | |
| title.textContent = "HTML Preview"; | |
| const closeBtn = document.createElement("button"); | |
| closeBtn.className = "html-preview-close"; | |
| closeBtn.innerHTML = "×"; | |
| closeBtn.addEventListener("click", () => overlay.remove()); | |
| header.appendChild(title); | |
| header.appendChild(closeBtn); | |
| const iframe = document.createElement("iframe"); | |
| iframe.className = "html-preview-iframe"; | |
| // Intentionally NOT including allow-same-origin: that would let the | |
| // user-supplied HTML reach into the parent page and steal cookies/state. | |
| iframe.sandbox = "allow-scripts allow-modals allow-forms allow-popups"; | |
| modal.appendChild(header); | |
| modal.appendChild(iframe); | |
| overlay.appendChild(modal); | |
| document.body.appendChild(overlay); | |
| iframe.srcdoc = htmlCode; | |
| document.addEventListener("keydown", function escHandler(e) { | |
| if (e.key === "Escape") { | |
| overlay.remove(); | |
| document.removeEventListener("keydown", escHandler); | |
| } | |
| }); | |
| } | |
| const HTML_TAG_RE = | |
| /<\/(div|body|html|section|main|head|style|script|p|h[1-6]|span|table|form|ul|ol|nav|header|footer|article)>/i; | |
| function looksLikeHtml(text) { | |
| const trimmed = text.trim(); | |
| if (!trimmed) return false; | |
| if ( | |
| trimmed.startsWith("<!DOCTYPE") || | |
| trimmed.startsWith("<!doctype") || | |
| trimmed.startsWith("<html") | |
| ) { | |
| return true; | |
| } | |
| return ( | |
| trimmed.startsWith("<") && | |
| trimmed.endsWith(">") && | |
| HTML_TAG_RE.test(trimmed) | |
| ); | |
| } | |
| function injectPreviewButtonFor(codeEl) { | |
| // ``previewBtnInserted`` — set once on the codeEl after we attach a | |
| // button. Must NEVER be set on a code element living inside | |
| // ``.hy-streaming`` because that container's innerHTML is replaced | |
| // on every delta — the codeEl gets discarded and a fresh one (no | |
| // flag) appears, while the button (a sibling outside .hy-streaming) | |
| // would silently leak. We only inject for code blocks that have | |
| // settled into ``.hy-frozen`` (append-only, never re-rendered). | |
| if (codeEl.dataset.previewBtnInserted) { | |
| codeEl.__hyPreviewText = codeEl.textContent || ""; | |
| return; | |
| } | |
| const pre = codeEl.closest("pre"); | |
| if (!pre) return; | |
| // Skip code blocks still in the streaming half. They'll be | |
| // re-evaluated once the freeze boundary advances past them and the | |
| // pre is moved into ``.hy-frozen`` as a fresh DOM node — at which | |
| // point this MutationObserver fires again with the new pre as an | |
| // added node and we inject exactly once. | |
| const frozenAncestor = pre.closest(".hy-frozen"); | |
| if (!frozenAncestor) return; | |
| const text = codeEl.textContent || ""; | |
| if (!looksLikeHtml(text)) return; | |
| codeEl.dataset.previewBtnInserted = "true"; | |
| codeEl.__hyPreviewText = text; | |
| const btn = document.createElement("button"); | |
| btn.className = "html-preview-btn"; | |
| btn.title = "Preview HTML"; | |
| btn.addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| openHtmlPreview(codeEl.__hyPreviewText || codeEl.textContent || ""); | |
| }); | |
| // Insert directly after the <pre> inside the same .hy-frozen | |
| // container. This binds the button's lifecycle to the specific | |
| // code block (instead of dumping all preview buttons into b.md as | |
| // siblings of .hy-frozen / .hy-streaming) and keeps its position | |
| // visually anchored to its source block. | |
| pre.parentElement.insertBefore(btn, pre.nextSibling); | |
| } | |
| /* ── Code-block chrome (header w/ lang label + copy button) ───────────── */ | |
| function copyText(text) { | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); | |
| } | |
| return fallbackCopy(text); | |
| } | |
| function fallbackCopy(text) { | |
| return new Promise((resolve, reject) => { | |
| try { | |
| const ta = document.createElement("textarea"); | |
| ta.value = text; | |
| ta.setAttribute("readonly", ""); | |
| ta.style.position = "absolute"; | |
| ta.style.left = "-9999px"; | |
| document.body.appendChild(ta); | |
| ta.select(); | |
| document.execCommand("copy"); | |
| document.body.removeChild(ta); | |
| resolve(); | |
| } catch (err) { | |
| reject(err); | |
| } | |
| }); | |
| } | |
| const COPY_ICON_SVG = | |
| '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' + | |
| 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' + | |
| 'stroke-linejoin="round" aria-hidden="true">' + | |
| '<rect x="9" y="9" width="11" height="11" rx="2" ry="2"></rect>' + | |
| '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' + | |
| "</svg>"; | |
| const CHECK_ICON_SVG = | |
| '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' + | |
| 'stroke="currentColor" stroke-width="2.5" stroke-linecap="round" ' + | |
| 'stroke-linejoin="round" aria-hidden="true">' + | |
| '<polyline points="4 12 10 18 20 6"></polyline>' + | |
| "</svg>"; | |
| // Pull the language label off the <code> element. ``marked`` always | |
| // emits ``class="language-X"`` from the fence info string, and | |
| // ``hljs.highlightElement`` may also add ``language-X`` after detection. | |
| // Anything else (no fence info, hljs failed) → blank label so the | |
| // header still renders consistently. | |
| function detectCodeLang(codeEl) { | |
| const cls = codeEl.className || ""; | |
| const m = cls.match(/(?:^|\s)language-([\w+#.-]+)/i); | |
| if (!m) return ""; | |
| const lang = m[1].toLowerCase(); | |
| // A few common aliases for nicer display. | |
| const ALIASES = { | |
| js: "javascript", | |
| ts: "typescript", | |
| sh: "shell", | |
| bash: "shell", | |
| zsh: "shell", | |
| yml: "yaml", | |
| md: "markdown", | |
| "c++": "cpp", | |
| }; | |
| return ALIASES[lang] || lang; | |
| } | |
| const COPY_LABEL = | |
| (window.HY_I18N && window.HY_I18N.code_copy) || "Copy"; | |
| const COPIED_LABEL = | |
| (window.HY_I18N && window.HY_I18N.code_copied) || "Copied"; | |
| function injectCopyButtonFor(codeEl) { | |
| const pre = codeEl.closest("pre"); | |
| if (!pre || pre.dataset.copyInjected) return; | |
| pre.dataset.copyInjected = "true"; | |
| // ── Build the wrapper card ──────────────────────────────────────── | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "hy-codeblock"; | |
| const header = document.createElement("div"); | |
| header.className = "hy-codeblock-header"; | |
| const lang = document.createElement("span"); | |
| lang.className = "hy-codeblock-lang"; | |
| lang.textContent = detectCodeLang(codeEl); | |
| header.appendChild(lang); | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "code-copy-btn"; | |
| btn.setAttribute("aria-label", COPY_LABEL); | |
| btn.title = COPY_LABEL; | |
| btn.innerHTML = COPY_ICON_SVG; | |
| let resetTimer = null; | |
| btn.addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| const text = codeEl.textContent || ""; | |
| copyText(text).then( | |
| () => { | |
| btn.innerHTML = CHECK_ICON_SVG; | |
| btn.classList.add("copied"); | |
| btn.title = COPIED_LABEL; | |
| if (resetTimer) clearTimeout(resetTimer); | |
| resetTimer = setTimeout(() => { | |
| btn.innerHTML = COPY_ICON_SVG; | |
| btn.classList.remove("copied"); | |
| btn.title = COPY_LABEL; | |
| }, 1400); | |
| }, | |
| () => { | |
| btn.title = "Copy failed"; | |
| if (resetTimer) clearTimeout(resetTimer); | |
| resetTimer = setTimeout(() => { | |
| btn.title = COPY_LABEL; | |
| }, 1400); | |
| } | |
| ); | |
| }); | |
| header.appendChild(btn); | |
| // ── Splice wrapper into the DOM around <pre> ───────────────────── | |
| // Insert the wrapper where <pre> lived, then move <pre> into it. | |
| // The mutation observer that drives this function will re-fire for | |
| // the wrapper's appearance, but ``dataset.copyInjected`` on <pre> | |
| // makes the second pass a no-op. | |
| const parent = pre.parentElement; | |
| if (!parent) return; | |
| parent.insertBefore(wrapper, pre); | |
| wrapper.appendChild(header); | |
| wrapper.appendChild(pre); | |
| } | |
| function processCodeBlock(codeEl) { | |
| injectCopyButtonFor(codeEl); | |
| injectPreviewButtonFor(codeEl); | |
| } | |
| function watchForHtmlBlocks() { | |
| const chatEl = document.querySelector(CHAT_SELECTOR); | |
| if (!chatEl) { | |
| setTimeout(watchForHtmlBlocks, 500); | |
| return; | |
| } | |
| chatEl.querySelectorAll("pre code").forEach(processCodeBlock); | |
| // Subscribers receive the whole rAF batch in one call. Within a | |
| // single batch the same code element may appear in many records | |
| // (one per streamed token); we de-dup with a Set so we only | |
| // re-evaluate ``injectPreviewButtonFor`` ONCE per affected code | |
| // element per frame instead of once per mutation. | |
| subscribeChatMutations((mutations) => { | |
| const codeElsToCheck = new Set(); | |
| for (let i = 0; i < mutations.length; i++) { | |
| const m = mutations[i]; | |
| if (m.type === "childList") { | |
| const added = m.addedNodes; | |
| for (let j = 0; j < added.length; j++) { | |
| const node = added[j]; | |
| if (!(node instanceof HTMLElement)) continue; | |
| if (node.matches && node.matches("pre code")) { | |
| processCodeBlock(node); | |
| } else if (node.querySelectorAll) { | |
| const found = node.querySelectorAll("pre code"); | |
| for (let k = 0; k < found.length; k++) { | |
| processCodeBlock(found[k]); | |
| } | |
| } | |
| } | |
| } else if (m.type === "characterData") { | |
| const el = m.target.parentElement; | |
| if (el && el.tagName === "CODE") codeElsToCheck.add(el); | |
| } | |
| } | |
| // Late-arriving HTML detection: a code block may not have looked | |
| // like HTML when first added but does now that more tokens have | |
| // streamed in. injectPreviewButtonFor is idempotent so calling it | |
| // again is safe and cheap. | |
| codeElsToCheck.forEach(injectPreviewButtonFor); | |
| }); | |
| } | |
| /* ── Send button: helpers ─────────────────────────────────────────────── */ | |
| function getSendButton() { | |
| const wrap = document.querySelector("#send-btn"); | |
| if (!wrap) return null; | |
| if (wrap.tagName === "BUTTON") return wrap; | |
| return wrap.querySelector("button"); | |
| } | |
| function setSendDisabled(disabled) { | |
| const btn = getSendButton(); | |
| if (!btn) return; | |
| if (disabled) { | |
| if (!btn.disabled) { | |
| btn.setAttribute("disabled", ""); | |
| btn.disabled = true; | |
| } | |
| } else { | |
| if (btn.disabled || btn.hasAttribute("disabled")) { | |
| btn.removeAttribute("disabled"); | |
| btn.disabled = false; | |
| } | |
| } | |
| } | |
| function streamMarkerPresent() { | |
| return !!document.querySelector(STREAM_MARKER_SELECTOR); | |
| } | |
| function idleMarkerPresent() { | |
| return !!document.querySelector(IDLE_MARKER_SELECTOR); | |
| } | |
| function isModelBusy() { | |
| const now = performance.now(); | |
| // Awaiting the server's first frame after a dispatch — must remain | |
| // busy regardless of marker state, with a hard timeout to avoid a | |
| // permanently-stuck button if the request silently failed. | |
| if (awaitingFirstFrame && now - lastSendAt < FIRST_FRAME_TIMEOUT_MS) { | |
| return true; | |
| } | |
| if (now - lastSendAt < SEND_DEBOUNCE_MS) return true; | |
| return streamMarkerPresent(); | |
| } | |
| /* ── Busy toast ───────────────────────────────────────────────────────── */ | |
| function ensureBusyToastHost() { | |
| let host = document.getElementById("hy-busy-toast-host"); | |
| if (host) return host; | |
| host = document.createElement("div"); | |
| host.id = "hy-busy-toast-host"; | |
| Object.assign(host.style, { | |
| position: "fixed", | |
| top: "24px", | |
| left: "50%", | |
| transform: "translateX(-50%)", | |
| zIndex: "9999", | |
| display: "flex", | |
| flexDirection: "column", | |
| gap: "8px", | |
| pointerEvents: "none", | |
| }); | |
| document.body.appendChild(host); | |
| return host; | |
| } | |
| let lastToastAt = 0; | |
| function showBusyToast() { | |
| const now = performance.now(); | |
| if (now - lastToastAt < 800) return; | |
| lastToastAt = now; | |
| const host = ensureBusyToastHost(); | |
| const toast = document.createElement("div"); | |
| toast.className = "hy-busy-toast"; | |
| toast.textContent = BUSY_MSG; | |
| Object.assign(toast.style, { | |
| background: "#1f2937", | |
| color: "#fff", | |
| padding: "10px 16px", | |
| borderRadius: "8px", | |
| boxShadow: "0 6px 20px rgba(0,0,0,0.25)", | |
| fontSize: "14px", | |
| maxWidth: "min(92vw, 520px)", | |
| lineHeight: "1.4", | |
| opacity: "0", | |
| transition: "opacity 180ms ease", | |
| pointerEvents: "none", | |
| }); | |
| host.appendChild(toast); | |
| requestAnimationFrame(() => { | |
| toast.style.opacity = "1"; | |
| }); | |
| setTimeout(() => { | |
| toast.style.opacity = "0"; | |
| setTimeout(() => toast.remove(), 250); | |
| }, 3200); | |
| } | |
| /* ── Send-button busy guard ───────────────────────────────────────────── */ | |
| // Three-layer disable signal, in priority order: | |
| // | |
| // 1. SEND_DEBOUNCE_MS round-trip window after the user dispatched | |
| // → disabled (prevents fast double-click before first server | |
| // frame arrives). | |
| // 2. #hy-busy-marker contains [data-hy-streaming="1"] | |
| // → disabled (server is actively streaming). | |
| // 3. Chat DOM has been *completely* idle for CHAT_IDLE_MS AND | |
| // we're past CHAT_IDLE_MIN_SINCE_SEND_MS since dispatch | |
| // → soft override: force-clear the marker and enable. Safety | |
| // net for "the server's IDLE marker frame was lost on the | |
| // wire". | |
| let busyStartedAt = 0; | |
| let lastChatMutationAt = 0; | |
| function syncSendButtonState() { | |
| const marker = streamMarkerPresent(); | |
| const now = performance.now(); | |
| if (marker) { | |
| // First server frame has arrived — stop relying on the dispatch | |
| // timer and hand over to the marker as the source of truth. | |
| awaitingFirstFrame = false; | |
| if (busyStartedAt === 0) busyStartedAt = now; | |
| const sinceSend = now - lastSendAt; | |
| const sinceMutation = now - lastChatMutationAt; | |
| if ( | |
| lastSendAt > 0 && | |
| sinceSend > CHAT_IDLE_MIN_SINCE_SEND_MS && | |
| sinceMutation > CHAT_IDLE_MS | |
| ) { | |
| console.warn( | |
| "[hy] Marker stuck while chat DOM idle for", | |
| (sinceMutation / 1000).toFixed(1), | |
| "s — force-clearing (server IDLE frame likely lost)." | |
| ); | |
| document.querySelectorAll(STREAM_MARKER_SELECTOR).forEach((el) => { | |
| el.removeAttribute("data-hy-streaming"); | |
| }); | |
| busyStartedAt = 0; | |
| setSendDisabled(false); | |
| return; | |
| } | |
| if (now - busyStartedAt > ABSOLUTE_BUSY_TIMEOUT_MS) { | |
| console.warn( | |
| "[hy] Stream marker stuck >", | |
| ABSOLUTE_BUSY_TIMEOUT_MS / 1000, | |
| "s — force-enabling Send." | |
| ); | |
| document.querySelectorAll(STREAM_MARKER_SELECTOR).forEach((el) => { | |
| el.removeAttribute("data-hy-streaming"); | |
| }); | |
| busyStartedAt = 0; | |
| setSendDisabled(false); | |
| return; | |
| } | |
| setSendDisabled(true); | |
| return; | |
| } | |
| busyStartedAt = 0; | |
| // Marker is idle. We may still be waiting for the FIRST frame after | |
| // a freshly-dispatched send: keep the button disabled so a second | |
| // click can't slip through during the initial request round-trip. | |
| if (awaitingFirstFrame) { | |
| // If the marker host explicitly contains the IDLE marker, the | |
| // server has already responded — typically a short-circuit path | |
| // like the functions-JSON validation failure in send_message, | |
| // which yields HY_IDLE_HTML without ever yielding HY_BUSY_HTML. | |
| // Without this branch, awaitingFirstFrame would stay set until | |
| // FIRST_FRAME_TIMEOUT_MS (30s), causing the next Enter/click to | |
| // wrongly trigger the "model is busy" toast. | |
| if (idleMarkerPresent()) { | |
| awaitingFirstFrame = false; | |
| } else if (now - lastSendAt > FIRST_FRAME_TIMEOUT_MS) { | |
| console.warn( | |
| "[hy] First-frame timeout — re-enabling Send (request likely failed)." | |
| ); | |
| awaitingFirstFrame = false; | |
| setSendDisabled(false); | |
| return; | |
| } else { | |
| setSendDisabled(true); | |
| return; | |
| } | |
| } | |
| if (now - lastSendAt < SEND_DEBOUNCE_MS) { | |
| setSendDisabled(true); | |
| return; | |
| } | |
| setSendDisabled(false); | |
| } | |
| function installBusyGuard() { | |
| const sendBtn = document.querySelector("#send-btn"); | |
| const textarea = document.querySelector(".msg-input textarea"); | |
| const markerHost = document.getElementById(BUSY_MARKER_HOST_ID); | |
| const chatEl = document.querySelector(CHAT_SELECTOR); | |
| if (!sendBtn || !textarea || !markerHost || !chatEl) { | |
| setTimeout(installBusyGuard, 500); | |
| return; | |
| } | |
| // Track chat DOM activity for the soft watchdog above. ANY mutation | |
| // refreshes the timestamp — including post-stream KaTeX/highlight | |
| // injection — because if anything is still rendering the streaming | |
| // MIGHT not be over yet. The watchdog only fires after a long quiet | |
| // period. | |
| lastChatMutationAt = performance.now(); | |
| subscribeChatMutations(() => { | |
| lastChatMutationAt = performance.now(); | |
| }); | |
| // Heartbeats — the server emits an empty-ops delta every ~5s during | |
| // long thinking pauses to keep the SSE/WebSocket connection alive | |
| // (see ``_HEARTBEAT_INTERVAL`` in core/chat.py). Empty-ops deltas | |
| // do NOT mutate ``#hy-chat`` so the MutationObserver above never | |
| // sees them, but they DO trigger the ``hy-chat:updated`` event that | |
| // applyDelta dispatches on every payload. Without this listener the | |
| // watchdog would mistakenly fire during legitimate thinking pauses | |
| // (especially with two windows in flight, where the per-request | |
| // first-content latency is higher) and unlock Send while the stream | |
| // is still running — letting the user dispatch a duplicate turn. | |
| chatEl.addEventListener("hy-chat:updated", () => { | |
| lastChatMutationAt = performance.now(); | |
| }); | |
| // Block clicks while busy. Capture phase + stopImmediatePropagation | |
| // ensures Gradio's own bubble-phase click handler never sees the | |
| // event when we want to reject it. | |
| // | |
| // CRITICAL: in the NOT-busy path we do NOT call setSendDisabled(true) | |
| // here. Gradio's Svelte Button checks ``this.disabled`` inside its | |
| // own click handler that runs AFTER ours; if we flip the disabled | |
| // attribute synchronously in capture, the actual send request is | |
| // silently swallowed. | |
| // Disable the Send button on the NEXT tick (after Gradio's own | |
| // bubble-phase click handler has run and dispatched the request). | |
| // We can't disable synchronously in capture or Gradio's Svelte | |
| // Button checks ``this.disabled`` and silently swallows the send. | |
| function lockSendAfterDispatch() { | |
| setTimeout(() => { | |
| setSendDisabled(true); | |
| }, 0); | |
| } | |
| sendBtn.addEventListener( | |
| "click", | |
| (e) => { | |
| if (isModelBusy()) { | |
| e.preventDefault(); | |
| e.stopImmediatePropagation(); | |
| e.stopPropagation(); | |
| showBusyToast(); | |
| return; | |
| } | |
| lastSendAt = performance.now(); | |
| awaitingFirstFrame = true; | |
| justSentNewMessage = true; | |
| lockSendAfterDispatch(); | |
| }, | |
| true | |
| ); | |
| // Same logic for Enter-to-submit on the textarea. Gradio's | |
| // Textbox.submit fires regardless of the Send button's disabled | |
| // state, so we ALSO need this gate. | |
| textarea.addEventListener( | |
| "keydown", | |
| (e) => { | |
| if (e.key !== "Enter" || e.shiftKey || e.isComposing) return; | |
| if (isModelBusy()) { | |
| e.preventDefault(); | |
| e.stopImmediatePropagation(); | |
| e.stopPropagation(); | |
| showBusyToast(); | |
| return; | |
| } | |
| lastSendAt = performance.now(); | |
| awaitingFirstFrame = true; | |
| justSentNewMessage = true; | |
| lockSendAfterDispatch(); | |
| }, | |
| true | |
| ); | |
| // Drive the busy state from mutations of the dedicated marker host. | |
| // Tiny subtree (one component, one inner span at most) so this is | |
| // essentially free. | |
| new MutationObserver(syncSendButtonState).observe(markerHost, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| attributes: true, | |
| attributeFilter: ["data-hy-streaming", "data-hy-busy"], | |
| }); | |
| // Slow heartbeat in case the MutationObserver misses something. | |
| setInterval(syncSendButtonState, 500); | |
| // Initial sync. | |
| syncSendButtonState(); | |
| } | |
| /* ── Follow-the-tail scroller ─────────────────────────────────────────── */ | |
| // Design (simple & robust): | |
| // * ``follow`` mirrors "user is currently near the bottom of the | |
| // chat". It is updated on EVERY scroll event — both user-driven | |
| // and programmatic — because after our own ``scrollToBottom`` the | |
| // position IS at the bottom, so follow stays true automatically. | |
| // * When the user scrolls UP, the very next scroll event flips | |
| // follow to false; subsequent mutations don't re-pin the view. | |
| // * When the user is actively scrolling (wheel / touchmove / | |
| // scroll-key) we briefly pause programmatic scrolling so we don't | |
| // fight their fingers; once they let go (no gesture for | |
| // USER_QUIET_MS) auto-follow resumes if they ended near bottom. | |
| // * Two redundant triggers ensure we never miss a delta: the global | |
| // chat MutationObserver AND the explicit ``hy-chat:updated`` | |
| // custom event dispatched by static/chat.js after each delta is | |
| // applied. | |
| function installScrollLock() { | |
| const chatEl = document.querySelector(CHAT_SELECTOR); | |
| if (!chatEl) { | |
| setTimeout(installScrollLock, 500); | |
| return; | |
| } | |
| const NEAR_BOTTOM_PX = 100; | |
| const USER_QUIET_MS = 120; | |
| let scrollable = null; | |
| let follow = true; | |
| let lastUserGestureAt = 0; | |
| function markUserGesture() { | |
| lastUserGestureAt = performance.now(); | |
| } | |
| window.addEventListener("wheel", markUserGesture, { | |
| passive: true, | |
| capture: true, | |
| }); | |
| window.addEventListener("touchmove", markUserGesture, { | |
| passive: true, | |
| capture: true, | |
| }); | |
| const SCROLL_KEYS = new Set([ | |
| "ArrowUp", | |
| "ArrowDown", | |
| "PageUp", | |
| "PageDown", | |
| "Home", | |
| "End", | |
| "Space", | |
| " ", | |
| ]); | |
| window.addEventListener( | |
| "keydown", | |
| (e) => { | |
| if (SCROLL_KEYS.has(e.key)) markUserGesture(); | |
| }, | |
| { capture: true } | |
| ); | |
| function isNearBottom(el) { | |
| return el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX; | |
| } | |
| function onScroll() { | |
| if (!scrollable) return; | |
| // Always reflect the current viewport state. After programmatic | |
| // scrollToBottom the position IS at the bottom, so follow=true. | |
| // After user scrolls up, follow flips to false on the very next | |
| // scroll event. Simple and self-correcting. | |
| follow = isNearBottom(scrollable); | |
| } | |
| function scrollToBottom() { | |
| if (!scrollable) return; | |
| // Don't fight an active user gesture. Once they stop (>120ms of | |
| // silence) we'll catch up on the very next mutation OR custom | |
| // event — both of which fire for every streamed delta. | |
| if (performance.now() - lastUserGestureAt < USER_QUIET_MS) return; | |
| const target = scrollable.scrollHeight - scrollable.clientHeight; | |
| if (target > scrollable.scrollTop) scrollable.scrollTop = target; | |
| } | |
| function findScrollable() { | |
| // Our chat element IS itself the scrollable container (see | |
| // _chat.css). Attach to it eagerly even before content overflows | |
| // so we catch the first scroll event the moment overflow appears. | |
| const s = getComputedStyle(chatEl); | |
| if (s.overflowY === "auto" || s.overflowY === "scroll") return chatEl; | |
| // Fallback for unexpected layouts: walk the subtree. | |
| const candidates = chatEl.querySelectorAll("*"); | |
| for (const el of candidates) { | |
| const cs = getComputedStyle(el); | |
| if (cs.overflowY === "auto" || cs.overflowY === "scroll") { | |
| if (el.scrollHeight > el.clientHeight + 4) return el; | |
| } | |
| } | |
| return chatEl; | |
| } | |
| function attach(el) { | |
| if (scrollable === el) return; | |
| if (scrollable) scrollable.removeEventListener("scroll", onScroll); | |
| scrollable = el; | |
| el.addEventListener("scroll", onScroll, { passive: true }); | |
| if (follow) scrollToBottom(); | |
| } | |
| // Attach immediately — chat.js may not have any content yet but the | |
| // element exists, so we can already wire the scroll listener. | |
| attach(findScrollable()); | |
| function tick() { | |
| if (justSentNewMessage) { | |
| follow = true; | |
| justSentNewMessage = false; | |
| } | |
| if (!scrollable || !document.contains(scrollable)) { | |
| attach(findScrollable()); | |
| } | |
| if (scrollable && follow) { | |
| scrollToBottom(); | |
| } | |
| } | |
| // Trigger 1: the global chat MutationObserver hub. Catches every | |
| // DOM change under #hy-chat (delta-driven appends, frozen flush, | |
| // streaming innerHTML swaps, post-stream highlight injection). | |
| subscribeChatMutations(tick); | |
| // Trigger 2: the explicit ``hy-chat:updated`` event dispatched by | |
| // static/chat.js at the end of every applyDelta. Belt-and-braces | |
| // in case some pathological mutation pattern slips past the rAF | |
| // batcher above. | |
| chatEl.addEventListener("hy-chat:updated", tick); | |
| // Trigger 3: window resize can change clientHeight which makes the | |
| // previous "at bottom" position no longer at bottom. Re-pin. | |
| window.addEventListener("resize", () => { | |
| if (scrollable && follow) scrollToBottom(); | |
| }); | |
| } | |
| /* ── kick off ─────────────────────────────────────────────────────────── */ | |
| function bootstrap() { | |
| const chatEl = document.querySelector(CHAT_SELECTOR); | |
| if (!chatEl) { | |
| // chat.js renders #hy-chat into the gr.HTML wrapper; until that | |
| // happens we can't install observers. Retry until ready — | |
| // subsystems below all guard for the same node themselves but | |
| // the mutation hub MUST be running before they install their | |
| // subscribers — otherwise early mutations would be missed. | |
| setTimeout(bootstrap, 200); | |
| return; | |
| } | |
| startChatMutationHub(chatEl); | |
| createOverlay(); | |
| watchForHtmlBlocks(); | |
| installScrollLock(); | |
| installBusyGuard(); | |
| } | |
| setTimeout(bootstrap, 800); | |
| })(); | |