PageSourceSearch

https://odysseus-lxcq.onrender.com/static/js/streamingRenderer.js

js odysseus-lxcq.onrender.com collected 2026-10-03 11:44:08 UTC 8,269 bytes, 206 lines download raw bytes

1// streamingRenderer.js
2//
3// The DOM shell for incremental streaming markdown rendering. One instance owns
4// the DOM of one streaming assistant message and is the only thing that writes to
5// it while it streams.
6//
7// It keeps the message as two regions, separated by an invisible comment marker so
8// the rendered blocks are direct children of the container (no wrapper elements to
9// disturb CSS):
10//
11//     [ finalized block, frozen ][ finalized block, frozen ] <!--tail--> [ live tail ]
12//
13//   - Finalized blocks are rendered once and never touched again — so code-block
14//     hover buttons can't flicker and code is highlighted exactly once.
15//   - The live tail (the still-growing trailing block) is re-rendered each token,
16//     except an open code fence, which streams in append-mode (text appended to a
17//     stable <pre>, highlighted once when it closes).
18//
19// All the "is this safe to freeze?" logic lives in the pure segmenter; this file
20// is deliberately mechanical. If anything throws, it latches into a full-re-render
21// fallback so a bug can never produce broken output — only today's behavior.
22
23import { splitFinalized, describeOpenFence } from './streamingSegmenter.js';
24
25// Compile-time escape hatch: set to false to force the plain full-re-render path.
26// (The per-instance try/catch `degraded` fallback below is the runtime safety net.)
27const ENABLED = true;
28
29export function createStreamRenderer(contentEl, { render, hljs } = {}) {
30  let started = false;
31  let tailMarker = null; // finalized nodes precede it; live-tail nodes follow it
32  let committedLen = 0; // chars of source already frozen
33  let lastText = ''; // most recent full text (for finalize)
34  let tailShownLen = 0; // rendered-text length of the live tail (drives token fade)
35  let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams
36  let degraded = !ENABLED; // true once we fall back to full re-render
37
38  function start() {
39    contentEl.textContent = '';
40    tailMarker = document.createComment('tail');
41    contentEl.appendChild(tailMarker);
42    started = true;
43  }
44
45  function highlight(root) {
46    if (hljs) root.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
47  }
48
49  function clearTail() {
50    while (tailMarker.nextSibling) tailMarker.nextSibling.remove();
51  }
52
53  // Render `src` and freeze the nodes before the tail marker. Highlighting happens
54  // here, once, on the detached fragment before the nodes are ever shown.
55  function freeze(src) {
56    const holder = document.createElement('div');
57    holder.innerHTML = render(src);
58    highlight(holder);
59    while (holder.firstChild) contentEl.insertBefore(holder.firstChild, tailMarker);
60  }
61
62  // Re-render the live tail. An open trailing fence streams in append-mode.
63  function renderTail(tailText) {
64    const fence = tailText ? describeOpenFence(tailText) : null;
65    if (fence) {
66      appendOpenFence(tailText, fence);
67      return;
68    }
69    appendMode = null;
70    clearTail();
71    if (!tailText) {
72      tailShownLen = 0;
73      return;
74    }
75    const holder = document.createElement('div');
76    holder.innerHTML = render(tailText);
77    fadeNewText(holder, tailShownLen);
78    tailShownLen = holder.textContent.length;
79    while (holder.firstChild) contentEl.appendChild(holder.firstChild);
80  }
81
82  // Stream the body of an unterminated code fence by appending only the new
83  // characters to a stable <pre><code> text node — no re-parse, no re-highlight.
84  function appendOpenFence(tailText, fence) {
85    if (!appendMode) {
86      clearTail();
87      const pre = document.createElement('pre');
88      const code = document.createElement('code');
89      if (fence.lang) code.className = `language-${fence.lang}`;
90      const textNode = document.createTextNode('');
91      code.appendChild(textNode);
92      pre.appendChild(code);
93      contentEl.appendChild(pre);
94      appendMode = { codeText: textNode, appendedLen: 0 };
95      tailShownLen = 0; // code is never faded; prose after the fence fades fresh
96    }
97    const code = tailText.slice(fence.contentStart);
98    if (code.length >
98 appendMode.appendedLen) {
99      appendMode.codeText.appendData(code.slice(appendMode.appendedLen));
100      appendMode.appendedLen = code.length;
101    }
102  }
103
104  // Wrap tail text past `prevLen` characters in <span class="token-new"> for the
105  // streaming fade-in. Skips code (<pre>) and thinking blocks (.thinking-content).
106  // Note: the original chat.js helper checked `.think-content`, a class that exists
107  // nowhere in the app, so thinking text used to fade; matching the real
108  // `.thinking-content` corrects that. Operates on the detached fragment before insertion.
109  function fadeNewText(container, prevLen) {
110    if (!prevLen) return;
111    const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
112    let count = 0;
113    const toWrap = [];
114    while (walker.nextNode()) {
115      const node = walker.currentNode;
116      const len = node.textContent.length;
117      if (count + len <= prevLen) {
118        count += len;
119        continue;
120      }
121      toWrap.push({ node, splitAt: count < prevLen ? prevLen - count : 0 });
122      count += len;
123    }
124    for (const { node, splitAt } of toWrap) {
125      const parent = node.parentNode;
126      if (!parent || parent.closest('pre, .thinking-content')) continue;
127      const target = splitAt > 0 ? node.splitText(splitAt) : node;
128      const span = document.createElement('span');
129      span.className = 'token-new';
130      parent.replaceChild(span, target);
131      span.appendChild(target);
132    }
133  }
134
135  function fullRender(fullText) {
136    contentEl.innerHTML = render(fullText);
137    highlight(contentEl);
138  }
139
140  // Render the latest full source text.
141  //
142  // PRECONDITION: callers must pass append-only text — each call's `fullText` must
143  // extend the previous one with the already-seen prefix UNCHANGED. Finalized
144  // blocks are frozen and never re-rendered, so a feed that rewrites earlier text
145  // would leave stale frozen blocks (corrected only by the next full re-render).
146  // chat.js satisfies this: its stripToolBlocks output only strips not-yet-finalized
147  // trailing tool syntax, never text that has already been frozen.
148  function update(fullText) {
149    lastText = fullText;
150    if (degraded) {
151      fullRender(fullText);
152      return;
153    }
154    try {
155      // Self-heal: if our DOM was replaced out from under us — chat.js writes
156      // contentEl.innerHTML directly for thinking indicators and tool blocks, and
157      // finalize() removes the marker — our tail marker is no longer a child of the
158      // container. Rebuild from scratch so we never append onto foreign content or
159      // touch a detached marker.
160      if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) {
161        started = false;
162        committedLen = 0;
163        tailShownLen = 0;
164        appendMode = null;
165      }
166      if (!started) start();
167      const next = splitFinalized(fullText, render, committedLen);
168      if (next > committedLen) {
169        freeze(fullText.slice(committedLen, next));
170        committedLen = next;
171        appendMode = null; // whatever was streaming is now frozen
172        tailShownLen = 0;
173      }
174      renderTail(fullText.slice(committedLen));
175    } catch (err) {
176      degraded = true;
177      console.error('streamingRenderer: falling back to full render', err);
178      fullRender(fullText);
179    }
180  }
181
182  // Stream finished: freeze whatever is left canonically and flatten away the
183  // marker so the container holds exactly what a single full render would produce.
184  // chat.js currently re-renders the finished message from source for its own
185  // reasons and so doesn't call this, but it completes the renderer's lifecycle and
186  // is exercised by the tests.
187  function finalize() {
188    if (degraded) return;
189    try {
190      if (!started) start();
191      clearTail();
192      appendMode = null;
193      const rest = lastText.slice(committedLen);
194      if (rest.trim()) freeze(rest);
195      tailMarker.remove();
196      tailMarker = null;
197      committedLen = lastText.length;
198    } catch (err) {
199      degraded = true;
200      console.error('streamingRenderer: falling back to full render', err);
201      fullRender(lastText);
202    }
203  }
204
205  return { update, finalize };
206}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.