| /* 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, "&") | |
| .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. | |
| // <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); | |
| } | |
| })(); | |