/* 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, //
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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // ─── 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. //
, 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 ``
``/````, 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 ``
`` 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 = ""; 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); } })();