Hy3 / static /chat.js
ericsqin's picture
add hy3
ad51766
Raw
History Blame Contribute Delete
29.5 kB
/* eslint-disable */
/**
* Hy3 custom chat renderer.
*
* The DOM under ``#hy-chat`` is owned exclusively by this module —
* nothing in Gradio touches it. The server pushes JSON deltas via the
* hidden ``#hy-chat-delta`` ``gr.HTML`` component; we observe its
* mutations, parse, and apply each op to the chat container.
*
* Design notes
* ────────────
* 1. Append-only DOM. Each new bubble is its own ``.message-row``.
* The browser's per-row layout/paint isolation (``contain`` +
* ``content-visibility`` from ``_perf.css``) keeps rendering cost
* O(visible) regardless of conversation length.
*
* 2. Frozen / streaming split inside each assistant bubble. As content
* streams in, every paragraph (or fenced code / display math) that
* has CLOSED gets rendered ONCE into a stable ``.hy-frozen`` DOM
* subtree and never touched again — KaTeX, ``marked``, and the
* syntax highlighter all run exactly once per closed block. Only
* the trailing in-progress paragraph re-renders on each delta,
* and that block is small. As a result already-rendered formulas
* never flicker / re-layout mid-stream.
*
* 3. Wire payload is ~just the new characters. A 100-char delta is
* ~150 bytes on the wire, so WebSocket back-pressure essentially
* goes away.
*
* Op contract — keep in sync with chat.py:
*
* {type: "reset"}
* {type: "user", text: "..."}
* {type: "assistant_begin", id: "a-N"}
* {type: "reasoning_delta", id: "a-N", delta: "..."}
* {type: "content_delta", id: "a-N", delta: "...", thinking_done?: bool}
* {type: "assistant_end", id: "a-N"}
* {type: "tool_call", id: "a-N", markdown: "..."}
* {type: "remove_bubble", id: "a-N"}
*/
(() => {
if (window.__HY_CHAT_INIT) return;
window.__HY_CHAT_INIT = true;
// ─── State ─────────────────────────────────────────────────────────────
const state = {
host: null, // <div id="hy-chat">
bubbles: new Map(), // bubble_id -> bubble record (see ensureBubble)
lastSeq: 0, // de-dup gate against re-fires of the same payload
// Current chat epoch — bumped server-side every time the user clicks
// "+" / new_chat. Each delta payload carries the epoch it was
// produced under; deltas whose epoch doesn't match ours are dropped
// (they're tail bytes from a stream the user already abandoned).
// Initialised lazily from the first payload that arrives so legacy
// payloads without an epoch field still apply.
currentEpoch: null,
};
// ─── DOM helpers ───────────────────────────────────────────────────────
function ensureHost() {
if (state.host && document.contains(state.host)) return state.host;
state.host = document.getElementById("hy-chat");
return state.host;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ─── Markdown + math renderer ──────────────────────────────────────────
// We DELIBERATELY do not sanitize here. The rest of the app depends
// on the model being able to emit raw HTML (e.g.
// <details class="thinking-block">, the HTML-preview workflow on raw
// HTML code blocks). The model is trusted output for this product
// surface; if that ever changes, swap marked for a sanitizing parser
// like markdown-it + DOMPurify.
const MARKED_OPTS = { gfm: true, breaks: false };
const KATEX_DELIMS = [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false },
// We pre-convert \[…\] / \(…\) → $$…$$ / $…$ via normalizeMathDelims
// BEFORE marked.parse runs (see below) — so the auto-render scan
// never needs to worry about them. They are NOT listed here on
// purpose: marked treats ``\[`` and ``\(`` as backslash escapes
// and silently drops the leading ``\`` in the rendered HTML, so by
// the time KaTeX scans the DOM the original bracket delimiters are
// already gone. Pre-conversion is the only reliable fix.
];
// ─── Math handling: two-stage pipeline ────────────────────────────────
//
// The model emits LaTeX in any of four delimiter styles: ``$$…$$``,
// ``\[…\]``, ``$…$``, ``\(…\)``. We need KaTeX (via auto-render) to
// pick all of them up while still letting ``marked.parse`` handle the
// surrounding markdown.
//
// Two problems block the naïve approach:
//
// 1. ``marked`` treats ``\[``, ``\]``, ``\(``, ``\)`` as backslash
// escapes and silently drops the ``\``. By the time KaTeX scans
// the rendered HTML the bracket delimiters are gone.
//
// 2. Even inside ``$$…$$``, ``marked`` strips backslashes before
// ``\{``, ``\}``, ``\_``, etc. — so things like ``\{(0:0:1:0)\}``
// lose their literal-brace escapes and KaTeX renders an empty
// group instead of the intended literal braces.
//
// The fix is two passes that together keep marked away from the math
// content:
//
// Stage A — ``normalizeMathDelims`` converts ``\[/\]`` → ``$$`` and
// ``\(/\)`` → ``$`` OUTSIDE fenced/inline code blocks. After this
// pass every math region is delimited with dollars.
//
// Stage B — ``protectMath`` extracts every ``$$…$$`` / ``$…$`` from
// the text and replaces it with a unique placeholder token (a NUL
// character that ``marked`` will leave alone). After ``marked.parse``
// we substitute the original math source back in. ``ignoredTags``
// already keeps KaTeX out of ``<pre>``/``<code>``, so the only
// regions that end up containing dollars in the rendered HTML are
// the math ones — exactly what we want.
//
// The placeholder uses NUL as a delimiter because ``marked`` cannot
// produce a literal NUL on its own (every input source we'd see is
// valid Unicode text without NULs), so the ``restoreMath`` regex is
// unambiguous.
function normalizeMathDelims(text) {
if (!text) return text;
let out = "";
let i = 0;
const n = text.length;
while (i < n) {
const ch = text[i];
// Triple-backtick or triple-tilde fenced block — passthrough until
// matching closing fence on its own (or anywhere on a) line.
if ((ch === "`" && text.startsWith("```", i)) ||
(ch === "~" && text.startsWith("~~~", i))) {
const marker = text.substr(i, 3);
out += marker;
i += 3;
const close = text.indexOf(marker, i);
if (close < 0) {
// Unclosed fence — emit rest verbatim and stop.
out += text.slice(i);
return out;
}
out += text.slice(i, close + 3);
i = close + 3;
continue;
}
// Inline code: single or double backticks, ended by the same run
// length. Common case is a single backtick.
if (ch === "`") {
let runLen = 1;
while (i + runLen < n && text[i + runLen] === "`") runLen++;
const open = text.substr(i, runLen);
out += open;
i += runLen;
const close = text.indexOf(open, i);
if (close < 0) {
out += text.slice(i);
return out;
}
out += text.slice(i, close + runLen);
i = close + runLen;
continue;
}
// Backslash-bracket / backslash-paren delimiters. Must be the
// FIRST conversion check after the code-fence skip so we don't
// mistake e.g. ``\[`` inside a code fence for math.
if (ch === "\\") {
if (text[i + 1] === "[") { out += "$$"; i += 2; continue; }
if (text[i + 1] === "]") { out += "$$"; i += 2; continue; }
if (text[i + 1] === "(") { out += "$"; i += 2; continue; }
if (text[i + 1] === ")") { out += "$"; i += 2; continue; }
}
out += ch;
i++;
}
return out;
}
// Inline math regex requires the opening ``$`` to be IMMEDIATELY
// followed by a non-space, non-``$`` char (avoids matching prices like
// ``I have $5 and $10``) and the closing ``$`` to be IMMEDIATELY
// preceded by a non-space, non-``$`` char. Display math (``$$…$$``)
// is matched first (the alternative is left-most-greedy in a JS
// regex, but with non-greedy bodies and ``$$`` being a longer prefix
// it wins ties naturally).
const MATH_RE = /\$\$[\s\S]+?\$\$|\$(?=[^\s$])[^\n$]*?(?<=[^\s$])\$/g;
function protectMath(text) {
const stash = [];
const escaped = text.replace(MATH_RE, (m) => {
const idx = stash.length;
stash.push(m);
return `\u0000M${idx}\u0000`;
});
return { escaped, stash };
}
function restoreMath(html, stash) {
if (!stash.length) return html;
return html.replace(/\u0000M(\d+)\u0000/g, (_, n) => stash[+n]);
}
function renderMarkdownInto(el, text) {
if (!text) {
el.innerHTML = "";
return;
}
const normalized = normalizeMathDelims(text);
const { escaped, stash } = protectMath(normalized);
const rawMd = (window.marked && window.marked.parse)
? window.marked.parse(escaped, MARKED_OPTS)
: escapeHtml(escaped);
el.innerHTML = restoreMath(rawMd, stash);
// Math
if (window.renderMathInElement) {
try {
window.renderMathInElement(el, {
delimiters: KATEX_DELIMS,
throwOnError: false,
// Skip math-detection inside elements that should never contain
// math (matches KaTeX auto-render defaults).
ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
});
} catch (err) {
console.warn("[hy-chat] katex render error:", err);
}
}
// Syntax highlighting (idempotent if hljs not loaded yet)
if (window.hljs && window.hljs.highlightElement) {
const codes = el.querySelectorAll("pre code");
for (let i = 0; i < codes.length; i++) {
const c = codes[i];
if (c.dataset.hljsDone) continue;
try {
window.hljs.highlightElement(c);
} catch (err) {
// hljs throws if the language is unknown — harmless, ignore.
}
c.dataset.hljsDone = "1";
}
}
}
// ─── Frozen / streaming split ──────────────────────────────────────────
// Find the highest "safe" boundary in the current content buffer up to
// which we can freeze the rendered DOM and never touch it again. A
// boundary is safe iff the markdown above it forms a complete set of
// top-level blocks — i.e. we are NOT inside an open fenced code block
// (``` / ~~~), display math ($$), or LaTeX env (\begin{…}).
//
// Returns the index in ``text`` immediately after the last safe block
// separator (\n\n).
function findFreezeBoundary(text) {
const len = text.length;
let i = 0;
let inFence = false; // inside ``` … ``` or ~~~ … ~~~
let fenceMarker = null;
let inMath = false; // inside $$ … $$
let inEnv = false; // inside \begin{…} … \end{…}
let lastSafe = 0;
while (i < len) {
const ch = text[i];
// Fence detection (must check before single-char paths)
if (!inMath && !inEnv) {
if (ch === "`" && text.startsWith("```", i)) {
if (!inFence) { inFence = true; fenceMarker = "```"; }
else if (fenceMarker === "```") { inFence = false; fenceMarker = null; }
i += 3;
continue;
}
if (ch === "~" && text.startsWith("~~~", i)) {
if (!inFence) { inFence = true; fenceMarker = "~~~"; }
else if (fenceMarker === "~~~") { inFence = false; fenceMarker = null; }
i += 3;
continue;
}
}
if (inFence) { i++; continue; }
// Display math $$ … $$
if (!inEnv && ch === "$" && text[i + 1] === "$") {
inMath = !inMath;
i += 2;
continue;
}
if (inMath) { i++; continue; }
// \begin{env} … \end{env}
if (ch === "\\") {
if (text.startsWith("\\begin{", i)) { inEnv = true; i += 7; continue; }
if (text.startsWith("\\end{", i)) { inEnv = false; i += 5; continue; }
}
if (inEnv) { i++; continue; }
// Paragraph break outside any block — candidate freeze point.
if (ch === "\n" && text[i + 1] === "\n") {
// Skip past the entire run of blank lines so we don't freeze
// mid-blank-paragraph.
let j = i + 2;
while (j < len && (text[j] === "\n" || text[j] === " " || text[j] === "\t")) j++;
lastSafe = j;
i = j;
continue;
}
i++;
}
return lastSafe;
}
// ─── Bubble lifecycle ──────────────────────────────────────────────────
function ensureBubble(id) {
return state.bubbles.get(id);
}
// Re-fire ``hy-chat:updated`` so the scroll-lock in app.js reconsiders
// whether to re-pin to the bottom now that geometry may have changed.
// Coalesced via rAF so a flurry of layout changes (e.g. KaTeX +
// hljs + table column-width pass all in the same frame) only
// dispatches once.
let _nudgePending = false;
function nudgeScrollSoon() {
if (_nudgePending) return;
_nudgePending = true;
requestAnimationFrame(() => {
_nudgePending = false;
if (!state.host) return;
state.host.dispatchEvent(new CustomEvent("hy-chat:updated", {
bubbles: false,
}));
});
}
// Forces ``row`` to flush any pending layout, then schedules a
// single rAF-coalesced ``hy-chat:updated`` dispatch. Used both
// when a ``<details>`` toggles and as the ``ResizeObserver``
// callback for each row.
function nudgeScrollAfterToggle(b) {
if (!b || !b.row) return;
void b.row.offsetHeight;
nudgeScrollSoon();
}
// Per-row ResizeObserver. Many things that change a row's height
// happen ASYNCHRONOUSLY after the streaming op that created the
// content: KaTeX glyphs render once their fonts arrive, hljs
// decorates code blocks on the next idle tick, browsers do a
// second column-width pass on tables once all rows are in. None
// of these go through ``applyDelta`` so the scroll-lock would
// otherwise miss them, leaving the user stuck at a now-stale
// ``scrollHeight - clientHeight``. Wiring a ResizeObserver per
// row covers all of them with one mechanism.
const _rowResizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => nudgeScrollSoon())
: null;
function createAssistantBubble(id) {
const row = document.createElement("div");
row.className = "message-row bot-row";
row.dataset.testid = "bot";
row.dataset.role = "assistant";
row.dataset.bubbleId = id;
const msg = document.createElement("div");
msg.className = "message bot";
const md = document.createElement("div");
md.className = "markdown hy-markdown";
const typing = document.createElement("div");
typing.className = "typing-indicator hy-typing";
typing.innerHTML = "<span></span><span></span><span></span>";
md.appendChild(typing);
msg.appendChild(md);
row.appendChild(msg);
state.host.appendChild(row);
if (_rowResizeObserver) _rowResizeObserver.observe(row);
const record = {
row, md, typing,
// Reasoning (optional, lazily created)
reasoningEl: null,
reasoningSummaryEl: null,
reasoningBodyEl: null,
reasoningBuf: "",
thinkingDone: false,
// Content (lazily created on first content_delta)
frozenEl: null,
streamingEl: null,
contentBuf: "",
frozenLen: 0,
// Tool-call append region (lazily created)
toolCallContainer: null,
ended: false,
};
state.bubbles.set(id, record);
return record;
}
function ensureReasoning(b) {
if (b.reasoningEl) return;
const det = document.createElement("details");
det.className = "thinking-block";
det.open = true;
const sum = document.createElement("summary");
sum.textContent = (window.HY_I18N && window.HY_I18N.thinking) || "Thinking...";
det.appendChild(sum);
const body = document.createElement("div");
body.className = "hy-reasoning-body";
body.style.whiteSpace = "pre-wrap";
body.style.wordBreak = "break-word";
det.appendChild(body);
b.reasoningEl = det;
b.reasoningSummaryEl = sum;
b.reasoningBodyEl = body;
if (b.typing && b.typing.parentNode) b.typing.remove();
// Reasoning always goes ABOVE content, even if content has already
// started rendering (rare — most models stream reasoning first).
b.md.insertBefore(det, b.md.firstChild);
// Keep the parent chat's scroll geometry in sync whenever the user
// (or markThinkingDone) toggles this block. See nudgeScrollAfterToggle.
det.addEventListener("toggle", () => nudgeScrollAfterToggle(b));
}
function ensureContent(b) {
if (b.frozenEl) return;
if (b.typing && b.typing.parentNode) b.typing.remove();
const frozen = document.createElement("div");
frozen.className = "hy-frozen";
const streaming = document.createElement("div");
streaming.className = "hy-streaming";
b.md.appendChild(frozen);
b.md.appendChild(streaming);
b.frozenEl = frozen;
b.streamingEl = streaming;
}
function markThinkingDone(b) {
if (!b.reasoningEl || b.thinkingDone) return;
b.thinkingDone = true;
b.reasoningSummaryEl.textContent =
(window.HY_I18N && window.HY_I18N.thinking_done) || "Thought";
b.reasoningEl.open = false;
}
// ─── Op handlers ───────────────────────────────────────────────────────
function opReset() {
if (_rowResizeObserver) {
for (const b of state.bubbles.values()) {
if (b.row) _rowResizeObserver.unobserve(b.row);
}
}
state.host.innerHTML = "";
state.bubbles.clear();
state.lastSeq = 0;
// currentEpoch is updated by applyDelta BEFORE op dispatch when a
// reset op is in the payload — see the epoch handling there. We
// intentionally do NOT clear it here, otherwise we'd reopen the
// window for stale post-reset deltas.
}
function opUser(op) {
const row = document.createElement("div");
row.className = "message-row user-row";
row.dataset.testid = "user";
row.dataset.role = "user";
const msg = document.createElement("div");
msg.className = "message user user-message";
const md = document.createElement("div");
md.className = "markdown hy-markdown hy-user-text";
// Render as plain text — never run user input through marked / KaTeX.
md.style.whiteSpace = "pre-wrap";
md.style.wordBreak = "break-word";
md.textContent = op.text || "";
msg.appendChild(md);
row.appendChild(msg);
state.host.appendChild(row);
}
function opAssistantBegin(op) {
if (state.bubbles.has(op.id)) return; // idempotent on retry
createAssistantBubble(op.id);
}
function opReasoningDelta(op) {
let b = ensureBubble(op.id);
if (!b) b = createAssistantBubble(op.id);
ensureReasoning(b);
const d = op.delta || "";
if (!d) return;
b.reasoningBuf += d;
// Reasoning is shown as raw text — fast and never re-renders math.
// Append-only via textContent means even very long reasoning stays
// O(delta) per update; the DOM tree is just a single text node.
b.reasoningBodyEl.textContent = b.reasoningBuf;
// Keep the latest reasoning line visible inside the (max-height-
// scrollable) details body.
b.reasoningBodyEl.scrollTop = b.reasoningBodyEl.scrollHeight;
}
function opContentDelta(op) {
let b = ensureBubble(op.id);
if (!b) b = createAssistantBubble(op.id);
if (op.thinking_done) markThinkingDone(b);
ensureContent(b);
const d = op.delta || "";
if (!d) return;
b.contentBuf += d;
// Compute new freeze boundary. If it advanced past the last frozen
// length, render JUST the newly-frozen chunk (text since last
// boundary) ONCE and APPEND its DOM to the frozen container. The
// already-frozen DOM is never touched — KaTeX, hljs, and marked
// run exactly once per closed block.
//
// Why we can render the new chunk in isolation: the boundary is
// always at a paragraph break (\n\n) outside any open code fence /
// display math / LaTeX env, so each chunk is a complete set of
// top-level markdown blocks that ``marked`` parses identically
// whether or not it has the prior context.
const text = b.contentBuf;
const newBoundary = findFreezeBoundary(text);
if (newBoundary > b.frozenLen) {
const newChunk = text.slice(b.frozenLen, newBoundary);
const temp = document.createElement("div");
renderMarkdownInto(temp, newChunk);
// Move children from temp into frozenEl. Moving (not cloning)
// preserves the KaTeX/hljs work we just did, and crucially does
// NOT re-trigger any rendering of the already-frozen DOM.
while (temp.firstChild) {
b.frozenEl.appendChild(temp.firstChild);
}
b.frozenLen = newBoundary;
}
// Streaming segment (everything after the boundary) is re-rendered
// in full on each delta. It's bounded to roughly one paragraph, so
// the KaTeX / marked / hljs work is O(paragraph) — and since
// ``innerHTML = ...`` wipes the previous DOM, KaTeX never
// double-renders the same formula here either.
const streamingText = text.slice(b.frozenLen);
renderMarkdownInto(b.streamingEl, streamingText);
}
function opAssistantEnd(op) {
const b = ensureBubble(op.id);
if (!b) return;
b.ended = true;
if (b.typing && b.typing.parentNode) b.typing.remove();
// Defer one extra nudge to the next animation frame: post-stream
// KaTeX/hljs decoration of the just-flushed tail can run after
// applyDelta returns, growing the bubble's height beyond what
// ``hy-chat:updated`` already saw at the end of this op.
nudgeScrollSoon();
// Final flush: take whatever is still in the streaming segment and
// append it to the frozen container as one last chunk, then empty
// streaming. Crucially we do NOT re-render the already-frozen DOM
// — that would re-run KaTeX over every formula the user has been
// looking at and produce a final visible flicker.
if (b.contentBuf) {
ensureContent(b);
const tail = b.contentBuf.slice(b.frozenLen);
if (tail) {
const temp = document.createElement("div");
renderMarkdownInto(temp, tail);
while (temp.firstChild) {
b.frozenEl.appendChild(temp.firstChild);
}
}
b.frozenLen = b.contentBuf.length;
b.streamingEl.innerHTML = "";
} else if (b.streamingEl) {
// No content at all (might be tool-call only) — clean up.
b.streamingEl.innerHTML = "";
}
if (b.reasoningEl && !b.thinkingDone) markThinkingDone(b);
}
function opToolCall(op) {
let b = ensureBubble(op.id);
if (!b) b = createAssistantBubble(op.id);
if (b.typing && b.typing.parentNode) b.typing.remove();
if (!b.toolCallContainer) {
const c = document.createElement("div");
c.className = "hy-tool-calls";
b.md.appendChild(c);
b.toolCallContainer = c;
}
const tc = document.createElement("div");
tc.className = "hy-tool-call";
renderMarkdownInto(tc, op.markdown || "");
b.toolCallContainer.appendChild(tc);
}
function opRemoveBubble(op) {
const b = ensureBubble(op.id);
if (!b) return;
if (_rowResizeObserver && b.row) _rowResizeObserver.unobserve(b.row);
if (b.row && b.row.parentNode) b.row.parentNode.removeChild(b.row);
state.bubbles.delete(op.id);
}
function applyOp(op) {
if (!op || !op.type) return;
if (!ensureHost()) return;
switch (op.type) {
case "reset": opReset(); break;
case "user": opUser(op); break;
case "assistant_begin": opAssistantBegin(op); break;
case "reasoning_delta": opReasoningDelta(op); break;
case "content_delta": opContentDelta(op); break;
case "assistant_end": opAssistantEnd(op); break;
case "tool_call": opToolCall(op); break;
case "remove_bubble": opRemoveBubble(op); break;
default:
console.warn("[hy-chat] unknown op type:", op.type);
}
}
function applyDelta(payload) {
if (!payload || typeof payload !== "object") return;
const seq = typeof payload.seq === "number" ? payload.seq : null;
// Dedup: Gradio occasionally re-fires the same component value on
// reconnect. Since each delta has a fresh global seq, anything ≤
// lastSeq is a re-fire we've already applied.
if (seq !== null && seq <= state.lastSeq) return;
if (seq !== null) state.lastSeq = seq;
const ops = Array.isArray(payload.ops) ? payload.ops : [];
// ── Stale-epoch drop ────────────────────────────────────────────
// The user clicked "+" mid-stream. The reset payload carries a NEW
// epoch; we adopt it and apply the reset. Any later payload from
// the cancelled generator still carries the OLD epoch and is
// dropped here — without this, late content_delta ops would
// ``createAssistantBubble`` for the old bubble id and a ghost
// bubble would appear in the freshly-cleared chat.
const epoch = payload.epoch;
if (epoch !== undefined && epoch !== null) {
const hasReset = ops.some((op) => op && op.type === "reset");
if (hasReset) {
state.currentEpoch = epoch;
} else if (state.currentEpoch === null) {
// First payload of the session: adopt whatever epoch we see.
state.currentEpoch = epoch;
} else if (epoch !== state.currentEpoch) {
return;
}
}
for (let i = 0; i < ops.length; i++) {
try {
applyOp(ops[i]);
} catch (err) {
console.error("[hy-chat] op failed:", ops[i], err);
}
}
// Notify subscribers (the scroll lock and code-block injector in
// app.js) that the chat DOM changed. We dispatch a single CustomEvent
// per applied delta — coarser than per-op so subscribers can do their
// own coalescing on top.
if (state.host) {
state.host.dispatchEvent(new CustomEvent("hy-chat:updated", {
bubbles: false,
}));
}
}
// ─── Wire up the delta channel ─────────────────────────────────────────
function startDeltaObserver() {
const deltaHost = document.getElementById("hy-chat-delta");
if (!deltaHost) {
setTimeout(startDeltaObserver, 200);
return;
}
let lastValue = "";
function handle() {
// Gradio may put the value in textContent or in a nested element
// depending on version. Walk the subtree to be defensive.
//
// While a generator is actively yielding, Gradio also injects a
// status / elapsed-time text node ("0.0s", "0.1s", …) INSIDE the
// output component's wrapper. That node bubbles into textContent
// and ticks every ~100ms, which would (a) make JSON.parse choke
// on the leading "0.0s" prefix and (b) re-fire the observer on
// every timer tick. Slice from the first '{' to the last '}' to
// skip the status overlay entirely.
const raw = (deltaHost.textContent || "");
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start < 0 || end < start) return;
const v = raw.slice(start, end + 1);
if (!v || v === lastValue) return;
lastValue = v;
let payload;
try {
payload = JSON.parse(v);
} catch (err) {
// Real malformed payload (rare): keep visibility, but at debug
// level so the timer overlay can't ever spam the console.
console.debug("[hy-chat] bad delta JSON:", v.slice(0, 200), err);
return;
}
applyDelta(payload);
}
new MutationObserver(handle).observe(deltaHost, {
childList: true,
subtree: true,
characterData: true,
});
handle(); // pick up initial value if any
}
function init() {
if (!ensureHost()) {
setTimeout(init, 200);
return;
}
startDeltaObserver();
}
// Public surface for the rest of the page (app.js).
window.HY_CHAT = {
init,
applyDelta,
getHost: ensureHost,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => setTimeout(init, 200));
} else {
setTimeout(init, 200);
}
})();