Hy3 / static /app.js
ericsqin's picture
add hy3
ad51766
Raw
History Blame Contribute Delete
38.7 kB
/* 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 = "&times;";
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);
})();