@cellgit/markdown-render 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +436 -178
  2. package/README.zh-CN.md +530 -0
  3. package/THIRD-PARTY-NOTICES.md +332 -0
  4. package/dist/markdown-render.css +3 -3
  5. package/dist/markdown-render.esm.css +3 -3
  6. package/dist/markdown-render.esm.js +1 -91452
  7. package/dist/markdown-render.html +20 -75
  8. package/dist/markdown-render.js +1 -91457
  9. package/dist/scripts/bridge.js +119 -0
  10. package/dist/scripts/chat-renderer.js +2770 -0
  11. package/dist/scripts/copy.js +155 -0
  12. package/dist/scripts/height-sync.js +205 -0
  13. package/dist/scripts/renderer.js +398 -0
  14. package/ios-example/MarkdownViewController.swift +123 -33
  15. package/ios-example/README.md +2 -0
  16. package/ios-example/SwiftUIMarkdownExample.swift +111 -0
  17. package/package.json +17 -9
  18. package/dist/fonts/KaTeX_AMS-Regular.ttf +0 -0
  19. package/dist/fonts/KaTeX_AMS-Regular.woff +0 -0
  20. package/dist/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  21. package/dist/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  22. package/dist/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  23. package/dist/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  24. package/dist/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  25. package/dist/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  26. package/dist/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  27. package/dist/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  28. package/dist/fonts/KaTeX_Main-Bold.ttf +0 -0
  29. package/dist/fonts/KaTeX_Main-Bold.woff +0 -0
  30. package/dist/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  31. package/dist/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  32. package/dist/fonts/KaTeX_Main-Italic.ttf +0 -0
  33. package/dist/fonts/KaTeX_Main-Italic.woff +0 -0
  34. package/dist/fonts/KaTeX_Main-Regular.ttf +0 -0
  35. package/dist/fonts/KaTeX_Main-Regular.woff +0 -0
  36. package/dist/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  37. package/dist/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  38. package/dist/fonts/KaTeX_Math-Italic.ttf +0 -0
  39. package/dist/fonts/KaTeX_Math-Italic.woff +0 -0
  40. package/dist/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  41. package/dist/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  42. package/dist/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  43. package/dist/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  44. package/dist/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  45. package/dist/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  46. package/dist/fonts/KaTeX_Script-Regular.ttf +0 -0
  47. package/dist/fonts/KaTeX_Script-Regular.woff +0 -0
  48. package/dist/fonts/KaTeX_Size1-Regular.ttf +0 -0
  49. package/dist/fonts/KaTeX_Size1-Regular.woff +0 -0
  50. package/dist/fonts/KaTeX_Size2-Regular.ttf +0 -0
  51. package/dist/fonts/KaTeX_Size2-Regular.woff +0 -0
  52. package/dist/fonts/KaTeX_Size3-Regular.ttf +0 -0
  53. package/dist/fonts/KaTeX_Size3-Regular.woff +0 -0
  54. package/dist/fonts/KaTeX_Size4-Regular.ttf +0 -0
  55. package/dist/fonts/KaTeX_Size4-Regular.woff +0 -0
  56. package/dist/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  57. package/dist/fonts/KaTeX_Typewriter-Regular.woff +0 -0
@@ -0,0 +1,2770 @@
1
+ (function (global) {
2
+ 'use strict';
3
+
4
+ const documentRef = global.document;
5
+ const bridge = global.MarkdownBridge;
6
+ const renderLib = global.MarkdownRender;
7
+
8
+ const MESSAGE_ROLES = new Set(['user', 'assistant', 'system']);
9
+ const MESSAGE_STATUSES = new Set(['pending', 'streaming', 'completed', 'failed', 'cancelled']);
10
+ /// Constructs a blank line does *not* end, because they are allowed to
11
+ /// contain one: a loose list keeps going across the gap between its items,
12
+ /// so cutting the document there would split one list into two. A table and
13
+ /// a blockquote are not in that company — a blank line ends both, and
14
+ /// treating a table row as unfinished business kept every table welded to
15
+ /// whatever followed it, in one block that could never be shown until the
16
+ /// paragraph after the paragraph after it had also arrived.
17
+ const LIST_LIKE = /^(?:[-*+]\s|\d+\.\s)/;
18
+ const graphemeSegmenter = global.Intl && typeof global.Intl.Segmenter === 'function'
19
+ ? new global.Intl.Segmenter(undefined, { granularity: 'grapheme' })
20
+ : null;
21
+
22
+ const defaultConfig = {
23
+ markdown: {},
24
+ extensions: null,
25
+ streaming: {
26
+ incremental: true,
27
+ // How to stream a block that never reaches a safe boundary. `lastSafe`
28
+ // only advances on a blank line, and a markdown table has none between
29
+ // its rows, so a long table stays in the unstable tail and is reparsed
30
+ // and repainted from scratch on every frame — cost grows with the square
31
+ // of the block length and shows up as heavy flicker.
32
+ // 'throttled' — keep revealing, but re-render less often as it grows.
33
+ // 'deferred' — hold the block back and render it once it completes.
34
+ longBlockMode: 'throttled',
35
+ longBlockThreshold: 512
36
+ },
37
+ chat: {
38
+ autoScroll: 'nearBottom',
39
+ preserveUserScroll: true,
40
+ bottomThreshold: 96,
41
+ streamingPresentation: 'smooth',
42
+ messageSpacing: '14px',
43
+ maxMessageWidth: '100%',
44
+ userMessageMaxWidth: '82%',
45
+ messageActions: {
46
+ enabled: false,
47
+ assistant: ['copy', 'retry'],
48
+ user: ['copy', 'edit'],
49
+ labels: {
50
+ copy: 'Copy response',
51
+ copied: 'Copied',
52
+ retry: 'Try again',
53
+ edit: 'Edit message'
54
+ }
55
+ },
56
+ // How a model's chain of thought is presented. Reasoning is context for
57
+ // the answer, never the answer itself: it streams into its own panel
58
+ // above the message and folds away the moment the answer starts.
59
+ reasoning: {
60
+ enabled: true,
61
+ // Route `<think>` … `</think>` out of the content stream. Turn this off
62
+ // when the host feeds reasoning through `appendReasoningChunk` only.
63
+ inlineTags: true,
64
+ tags: null,
65
+ autoCollapse: true,
66
+ defaultExpanded: false,
67
+ labels: {
68
+ thinking: 'Thinking…',
69
+ done: 'Thought process',
70
+ duration: 'Thought for {seconds}s'
71
+ }
72
+ }
73
+ }
74
+ };
75
+ const runtimeConfig = Object.assign({}, defaultConfig, global.MarkdownWebViewConfig || {});
76
+ runtimeConfig.chat = Object.assign({}, defaultConfig.chat, runtimeConfig.chat || {});
77
+ runtimeConfig.chat.messageActions = Object.assign(
78
+ {},
79
+ defaultConfig.chat.messageActions,
80
+ runtimeConfig.chat.messageActions || {}
81
+ );
82
+ runtimeConfig.chat.messageActions.labels = Object.assign(
83
+ {},
84
+ defaultConfig.chat.messageActions.labels,
85
+ runtimeConfig.chat.messageActions.labels || {}
86
+ );
87
+ runtimeConfig.chat.reasoning = Object.assign(
88
+ {},
89
+ defaultConfig.chat.reasoning,
90
+ runtimeConfig.chat.reasoning || {}
91
+ );
92
+ runtimeConfig.chat.reasoning.labels = Object.assign(
93
+ {},
94
+ defaultConfig.chat.reasoning.labels,
95
+ runtimeConfig.chat.reasoning.labels || {}
96
+ );
97
+
98
+ const state = {
99
+ mounted: false,
100
+ container: null,
101
+ messages: new Map(),
102
+ order: [],
103
+ heightFrame: null,
104
+ scrollFrame: null,
105
+ viewportFrame: null,
106
+ viewportListenersInstalled: false,
107
+ shouldStickToBottom: true,
108
+ // The message currently held at the top of the view, and the empty element
109
+ // that gives it the room to get there — see `ensureTailRoom`.
110
+ anchorId: null,
111
+ tailRoom: null
112
+ };
113
+
114
+ function notifyError(error, reason) {
115
+ if (bridge && typeof bridge.notifyError === 'function') {
116
+ bridge.notifyError(error, reason || 'chat');
117
+ }
118
+ }
119
+
120
+ function raf(callback) {
121
+ if (typeof global.requestAnimationFrame === 'function') {
122
+ return global.requestAnimationFrame(callback);
123
+ }
124
+ return setTimeout(callback, 16);
125
+ }
126
+
127
+ function cancelFrame(handle) {
128
+ if (!handle) return;
129
+ if (typeof global.cancelAnimationFrame === 'function') {
130
+ global.cancelAnimationFrame(handle);
131
+ } else {
132
+ clearTimeout(handle);
133
+ }
134
+ }
135
+
136
+ /// Elapsed milliseconds, for pacing work rather than telling the time.
137
+ /// `performance.now()` is monotonic; `Date.now()` is the fallback for hosts
138
+ /// without it and can jump if the clock is adjusted mid-response.
139
+ function nowMs() {
140
+ if (global.performance && typeof global.performance.now === 'function') {
141
+ return global.performance.now();
142
+ }
143
+ return Date.now();
144
+ }
145
+
146
+ /// Write an attribute only when it actually changes. An unconditional
147
+ /// setAttribute still counts as a mutation: it invalidates style for every
148
+ /// selector that reads the attribute, and the chat stylesheet keys its
149
+ /// first-child/last-child margin rules off exactly these flags. Rewriting
150
+ /// them on every streamed frame re-resolved those margins hundreds of times
151
+ /// a second, which is felt as the message breathing while it types.
152
+ function setAttr(node, name, value) {
153
+ if (node.getAttribute(name) !== value) {
154
+ node.setAttribute(name, value);
155
+ }
156
+ }
157
+
158
+ /// Accept either an HTML string or a pre-parsed fragment from the renderer.
159
+ function asFragment(content) {
160
+ if (content && typeof content === 'object' && content.nodeType === 11) {
161
+ return content;
162
+ }
163
+ const template = documentRef.createElement('template');
164
+ template.innerHTML = String(content == null ? '' : content);
165
+ return template.content;
166
+ }
167
+
168
+ function newScanState() {
169
+ return {
170
+ cursor: 0,
171
+ lineStart: 0,
172
+ inFence: false,
173
+ fenceMark: '',
174
+ fenceAtMargin: false,
175
+ inMath: false,
176
+ lastSafe: 0,
177
+ prevNonBlankLine: null,
178
+ // Every offset at which one top-level block ends and the next begins.
179
+ // The reveal engine works a block at a time, so it needs all of them and
180
+ // not only the most recent one.
181
+ boundaries: [],
182
+ boundariesTaken: 0
183
+ };
184
+ }
185
+
186
+ /// One top-level markdown block: a paragraph, a list, a fenced code block, a
187
+ /// table, a display formula.
188
+ ///
189
+ /// Blocks are the unit everything downstream works in. Each is revealed,
190
+ /// rendered, faded and finally frozen on its own, and a block that has
191
+ /// finished all four is never rendered again for the rest of the
192
+ /// conversation — which is what keeps the cost of a streamed frame
193
+ /// proportional to the block being written rather than to the answer so far.
194
+ function newBlock(start) {
195
+ return {
196
+ start,
197
+ source: '',
198
+ complete: false,
199
+ // Whole or nothing. A table's column widths are decided by its widest
200
+ // cell and a formula is not a formula until its closing delimiter
201
+ // arrives; revealed a piece at a time, both re-lay-out everything already
202
+ // on screen on every frame. An atomic block is held back until it is
203
+ // complete and then appears as the one thing it is.
204
+ atomic: false,
205
+ // ...with one exception: a table past its delimiter row is released a
206
+ // whole row at a time rather than held to the end. See `classifyBlock`.
207
+ rowwise: false,
208
+ revealed: 0,
209
+ // Fence and math state of the *revealed* prefix, which is what decides
210
+ // whether the next line may be shown a character at a time.
211
+ revealScan: null,
212
+ container: null,
213
+ renderedLength: 0,
214
+ // Something changed that the DOM has not been told about yet.
215
+ dirty: false,
216
+ // Frozen: complete, fully revealed, done animating. Never touched again.
217
+ settled: false,
218
+ // The whole-block fade an atomic block gets in place of a typewriter.
219
+ entering: false,
220
+ enteredAt: 0,
221
+ enterApplied: false,
222
+ // Runs of freshly revealed text still fading in — see `paintBlockFade`.
223
+ fadeRuns: [],
224
+ fadeBreak: false,
225
+ // Fade wrappers are currently in the DOM, so the next paint has to take
226
+ // them back out even if nothing else about the block changed.
227
+ decorated: false,
228
+ lastRenderCost: 0,
229
+ lastRenderAt: 0
230
+ };
231
+ }
232
+
233
+ function newStreamState(markdown) {
234
+ return {
235
+ scanState: newScanState(),
236
+ // Everything the host has handed over, whether or not it is on screen.
237
+ received: typeof markdown === 'string' ? markdown : '',
238
+ blocks: [],
239
+ // The block the typewriter is working on. Prose is revealed strictly in
240
+ // order; atomic blocks are not, which is why this is not simply the last
241
+ // block with anything on screen.
242
+ laneCursor: 0,
243
+ // The host has said that no more text is coming.
244
+ ended: false,
245
+ // Show everything at once on the next flush, rather than pacing it.
246
+ instant: false,
247
+ pendingFrame: null,
248
+ pendingReason: 'render',
249
+ // Playout pacing — see `revealBudget`.
250
+ startedAt: 0,
251
+ originLength: 0,
252
+ drainRate: 0,
253
+ lastRevealAt: 0,
254
+ revealDebt: 0,
255
+ renderingInProgress: false,
256
+ finalRequested: false
257
+ };
258
+ }
259
+
260
+ /// One independently streamed region of a message.
261
+ ///
262
+ /// A message always has an answer channel, and grows a second one the moment
263
+ /// the model reveals any reasoning. Both run the identical incremental
264
+ /// pipeline over their own buffer and their own list of blocks — the
265
+ /// reasoning fold is not a special case of rendering, only of placement.
266
+ function newChannel(kind, container, markdown) {
267
+ container.setAttribute('data-empty', 'true');
268
+
269
+ return {
270
+ kind,
271
+ content: container,
272
+ // The block containers currently carrying the first and last margin
273
+ // reset, so the flags only move when the edges actually move.
274
+ firstEdge: null,
275
+ lastEdge: null,
276
+ stream: newStreamState(markdown)
277
+ };
278
+ }
279
+
280
+ function reasoningConfig() {
281
+ return runtimeConfig.chat.reasoning;
282
+ }
283
+
284
+ /// Inline extraction needs both the feature switch and a render core that
285
+ /// actually ships the splitter — an older bundle simply keeps the markers in
286
+ /// the answer, where the markdown-level fold still catches them.
287
+ function inlineReasoningEnabled() {
288
+ const config = reasoningConfig();
289
+ return Boolean(config.enabled && config.inlineTags)
290
+ && Boolean(renderLib)
291
+ && typeof renderLib.createReasoningSplitter === 'function';
292
+ }
293
+
294
+ function usesSmoothStreaming() {
295
+ return runtimeConfig.chat.streamingPresentation !== 'immediate';
296
+ }
297
+
298
+ /// How many grapheme clusters may be revealed in a single frame. The reveal
299
+ /// rate is otherwise proportional to the backlog, so this only bounds the
300
+ /// worst case — a model that dumps a whole answer at once.
301
+ const MAX_UNITS_PER_FRAME = 96;
302
+ const MAX_UNITS_PER_FINAL_FRAME = 384;
303
+
304
+ /// Number of characters covering at most `maxUnits` grapheme clusters.
305
+ ///
306
+ /// Segmenting the entire backlog on every frame is quadratic over a long
307
+ /// answer — and pointless, since at most `maxUnits` clusters are consumed.
308
+ /// Only a bounded window at the head is ever examined.
309
+ function graphemeSafeCut(text, maxUnits) {
310
+ if (maxUnits >= text.length) return text.length;
311
+
312
+ if (!graphemeSegmenter) {
313
+ // Iterate code points so a surrogate pair is never split in half.
314
+ let chars = 0;
315
+ let units = 0;
316
+ for (const codePoint of text) {
317
+ if (units >= maxUnits) break;
318
+ chars += codePoint.length;
319
+ units += 1;
320
+ }
321
+ return chars;
322
+ }
323
+
324
+ const windowSize = Math.min(text.length, maxUnits * 8 + 32);
325
+ const window = text.slice(0, windowSize);
326
+ let chars = 0;
327
+ let units = 0;
328
+ for (const item of graphemeSegmenter.segment(window)) {
329
+ if (units >= maxUnits) break;
330
+ chars += item.segment.length;
331
+ units += 1;
332
+ }
333
+ // The window may have cut a cluster in half; if we stopped because the
334
+ // window ran out rather than because the budget did, hold that cluster
335
+ // back for the next frame.
336
+ if (units < maxUnits && chars === windowSize && windowSize < text.length) {
337
+ return Math.max(1, chars - 1);
338
+ }
339
+ return Math.max(1, chars);
340
+ }
341
+
342
+ function openingFenceMark(line) {
343
+ const match = String(line || '').match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
344
+ if (!match) return '';
345
+ if (match[1][0] === '`' && match[2].includes('`')) return '';
346
+ return match[1];
347
+ }
348
+
349
+ function isClosingFenceLine(line, fenceMark) {
350
+ if (!fenceMark) return false;
351
+ const match = String(line || '').match(/^ {0,3}(`+|~+)([ \t]*)$/);
352
+ return Boolean(
353
+ match
354
+ && match[1][0] === fenceMark[0]
355
+ && match[1].length >= fenceMark.length
356
+ );
357
+ }
358
+
359
+ function fencePrefixKind(line, scanState) {
360
+ const match = String(line || '').match(/^ {0,3}(`+|~+)(.*)$/);
361
+ if (!match) return 'none';
362
+
363
+ const mark = match[1];
364
+ const suffix = match[2];
365
+ if (scanState.inFence) {
366
+ if (!scanState.fenceMark || mark[0] !== scanState.fenceMark[0]) return 'none';
367
+ if (suffix !== '' && !/^[ \t]*$/.test(suffix)) return 'none';
368
+ return mark.length >= scanState.fenceMark.length ? 'fence' : 'possible';
369
+ }
370
+
371
+ if (mark.length < 3) {
372
+ return suffix === '' ? 'possible' : 'none';
373
+ }
374
+ if (mark[0] === '`' && suffix.includes('`')) return 'none';
375
+ return 'fence';
376
+ }
377
+
378
+ function currentLinePrefix(text) {
379
+ const source = String(text || '');
380
+ const newline = source.lastIndexOf('\n');
381
+ return newline < 0 ? source : source.slice(newline + 1);
382
+ }
383
+
384
+ /// Lines whose shape only exists once the line is whole.
385
+ ///
386
+ /// A table row is the clearest case: column widths are decided by the widest
387
+ /// cell in each column, so a row revealed a character at a time widens a
388
+ /// column on one frame and widens it again on the next, dragging every row
389
+ /// above it sideways — the table appears to vibrate rather than grow. A
390
+ /// display-math delimiter is the same story: half of `$$` is a stray dollar
391
+ /// sign in a paragraph. Both are revealed a whole line at a time, so a table
392
+ /// advances by rows rather than by characters — on a slow model that is a
393
+ /// visible pause between rows, which is the price of the table never moving
394
+ /// sideways while it fills in.
395
+ ///
396
+ /// Ordinary prose is deliberately not in this set: it reflows as it grows by
397
+ /// design, and holding a paragraph back until its newline would replace the
398
+ /// typewriter with a block that appears all at once.
399
+ const STRUCTURED_LINE = /^ {0,3}(?:\||\$\$)/;
400
+
401
+ function isStructuredLine(line, scanState) {
402
+ // Inside a fence every line is literal text in a canvas that never
403
+ // reflows, so there is nothing to protect and holding lines back would
404
+ // only stall the typing.
405
+ if (scanState.inFence) return false;
406
+ return STRUCTURED_LINE.test(line);
407
+ }
408
+
409
+ /// A line that opens a table row, and a line that could still turn into the
410
+ /// delimiter row proving the first one was a header rather than a paragraph
411
+ /// that happens to start with a pipe.
412
+ ///
413
+ /// Deliberately generous: a line made only of pipes, dashes, colons and
414
+ /// spaces either is a delimiter row or is a keystroke away from being one,
415
+ /// and while the model is still typing it there is no way to tell. Guessing
416
+ /// "not a table" and being wrong shows the reader a header row as a
417
+ /// paragraph and then replaces it with a table; guessing "table" and being
418
+ /// wrong costs one line's delay.
419
+ const TABLE_ROW_LINE = /^ {0,3}\|/;
420
+ const TABLE_DELIMITER_LINE = /^ {0,3}[\s:|-]*$/;
421
+ const DISPLAY_MATH_LINE = /^ {0,3}\$\$/;
422
+
423
+ /// The lines of `source` that have certainly ended.
424
+ ///
425
+ /// A line still being typed is not one of them, and the difference matters:
426
+ /// half a delimiter row is indistinguishable from a paragraph that happens
427
+ /// to open with a pipe.
428
+ function settledLines(source, limit) {
429
+ const lastNewline = source.lastIndexOf('\n');
430
+ if (lastNewline < 0) return [];
431
+ return firstMeaningfulLines(source.slice(0, lastNewline + 1), limit);
432
+ }
433
+
434
+ function firstMeaningfulLines(source, limit) {
435
+ const lines = [];
436
+ let cursor = 0;
437
+ while (cursor < source.length && lines.length < limit) {
438
+ let end = source.indexOf('\n', cursor);
439
+ if (end < 0) end = source.length;
440
+ const line = source.slice(cursor, end);
441
+ if (line.trim() !== '') lines.push(line);
442
+ cursor = end + 1;
443
+ }
444
+ return lines;
445
+ }
446
+
447
+ /// Decide whether a block is one that only makes sense whole.
448
+ ///
449
+ /// Called on every ingest while the block is still growing, which is why it
450
+ /// may only ever *lower* the bar: a block that has already begun to be
451
+ /// revealed cannot be taken back and shown atomically. That is why a block
452
+ /// opening with a pipe is assumed to be a table until its second line proves
453
+ /// otherwise — holding a paragraph back for one line costs a frame, while
454
+ /// revealing half a table costs the reader every column width in it.
455
+ function classifyBlock(block) {
456
+ const lines = firstMeaningfulLines(block.source, 2);
457
+ const first = lines[0] || '';
458
+
459
+ if (DISPLAY_MATH_LINE.test(first)) {
460
+ block.atomic = true;
461
+ return;
462
+ }
463
+ if (TABLE_ROW_LINE.test(first)) {
464
+ block.atomic = lines.length < 2 || TABLE_DELIMITER_LINE.test(lines[1]);
465
+ // A table is atomic in the sense that matters — it is never typed into,
466
+ // and a header row is never shown as a paragraph of pipes — but it does
467
+ // not have to be held back until its last row. Once the delimiter row
468
+ // has arrived whole, every further row is a complete unit that can go on
469
+ // screen the moment it lands. Twenty rows' worth of nothing followed by
470
+ // the whole table at once is not a table being written; past a couple of
471
+ // seconds it reads as an app that has hung.
472
+ block.rowwise = block.atomic && settledLines(block.source, 2).length >= 2;
473
+ return;
474
+ }
475
+ block.atomic = false;
476
+ }
477
+
478
+ /// How much text the reveal deliberately holds back, in milliseconds.
479
+ ///
480
+ /// A model does not produce text at a steady rate. Tokens arrive in bursts
481
+ /// separated by gaps of tens or hundreds of milliseconds, and a reveal that
482
+ /// simply shows whatever has arrived reproduces every one of those bursts as
483
+ /// a jump. Keeping a fifth of a second of text in hand and paying it out at
484
+ /// the rate it is arriving turns the bursts back into a stream — the same
485
+ /// trick a media player uses against a jittery network, and the reason the
486
+ /// answer reads as being typed rather than as being pasted in pieces.
487
+ ///
488
+ /// The lag is also what makes the reveal *concurrent*: a table or a formula
489
+ /// further down the response is routinely complete while the paragraph above
490
+ /// it is still being typed, and holding it back until the typewriter reached
491
+ /// it would be inventing a delay the reader has no use for.
492
+ const PLAYOUT_LAG_MS = 220;
493
+ /// How quickly a backlog that is off target is brought back to it. Long
494
+ /// enough that correcting for a burst does not become a burst of its own.
495
+ const PLAYOUT_CORRECTION_MS = 700;
496
+ /// The window the arrival rate is measured over before it is trusted. The
497
+ /// first chunk of a response arrives milliseconds after the request, and
498
+ /// dividing by that would report a model typing at tens of thousands of
499
+ /// characters a second.
500
+ const PLAYOUT_WARMUP_MS = 200;
501
+ /// Rate bounds, in characters per millisecond.
502
+ const MIN_PLAYOUT_RATE = 0.012;
503
+ const MAX_PLAYOUT_RATE = 1.5;
504
+ /// Once the response is finished there is no arrival rate left to smooth
505
+ /// against, so whatever is still held back is paid out over a fixed window.
506
+ /// Long enough to stay a reveal, short enough that a reader is never left
507
+ /// watching a finished answer type itself out.
508
+ const FINAL_DRAIN_MS = 520;
509
+ /// Floor under the drain rate, in characters per millisecond. A window alone
510
+ /// does not end: dividing what is *left* by a fixed window every frame is
511
+ /// exponential decay, which halves the backlog forever and never spends it.
512
+ const MIN_DRAIN_RATE = 0.06;
513
+ /// How much of a message may appear on the frame it first has anything to
514
+ /// show. The playout buffer is about keeping a stream *continuous*, and there
515
+ /// is no continuity to protect before the first character: holding one back
516
+ /// would only leave the reader watching an empty bubble for a fifth of a
517
+ /// second while the model was already answering.
518
+ const FIRST_REVEAL_UNITS = 24;
519
+
520
+ function playoutLagMs() {
521
+ const configured = (runtimeConfig.streaming || {}).playoutLagMs;
522
+ if (typeof configured !== 'number' || !isFinite(configured) || configured < 0) {
523
+ return PLAYOUT_LAG_MS;
524
+ }
525
+ return configured;
526
+ }
527
+
528
+ /// Characters the typewriter still owes the reader. Atomic blocks are not
529
+ /// counted: they never pass through the typewriter, so pacing against them
530
+ /// would make the prose sprint every time a table arrived.
531
+ function laneBacklog(stream) {
532
+ let total = 0;
533
+ for (let index = 0; index < stream.blocks.length; index += 1) {
534
+ const block = stream.blocks[index];
535
+ if (block.atomic) continue;
536
+ total += block.source.length - block.revealed;
537
+ }
538
+ return total;
539
+ }
540
+
541
+ /// How many grapheme clusters the typewriter may reveal this frame.
542
+ function revealBudget(stream, now) {
543
+ const backlog = laneBacklog(stream);
544
+ if (backlog <= 0) {
545
+ stream.lastRevealAt = now;
546
+ stream.revealDebt = 0;
547
+ return 0;
548
+ }
549
+
550
+ // Clamped, because a frame the host spent elsewhere is not a frame's worth
551
+ // of text the reader missed.
552
+ const elapsed = stream.lastRevealAt > 0 ? Math.min(96, now - stream.lastRevealAt) : 16;
553
+ stream.lastRevealAt = now;
554
+
555
+ if (revealedTotal(stream) === 0) return Math.min(backlog, FIRST_REVEAL_UNITS);
556
+
557
+ let perMs;
558
+ let ceiling;
559
+ if (stream.ended) {
560
+ // Fixed the moment the response ended, from the backlog as it stood
561
+ // then. Recomputing it every frame from what is left would make the
562
+ // reveal slow down exactly as it approached the end.
563
+ if (!stream.drainRate) {
564
+ stream.drainRate = Math.max(MIN_DRAIN_RATE, backlog / FINAL_DRAIN_MS);
565
+ }
566
+ perMs = stream.drainRate;
567
+ ceiling = MAX_UNITS_PER_FINAL_FRAME;
568
+ } else {
569
+ const age = Math.max(PLAYOUT_WARMUP_MS, now - stream.startedAt);
570
+ const arrived = Math.max(0, stream.received.length - stream.originLength);
571
+ const rate = Math.min(MAX_PLAYOUT_RATE, Math.max(MIN_PLAYOUT_RATE, arrived / age));
572
+ // Pay out at the rate text is arriving, plus whatever it takes to bring
573
+ // the amount held back to the target lag. Rate alone would preserve
574
+ // whatever lag the first burst happened to create, for the whole answer.
575
+ perMs = rate + (backlog - rate * playoutLagMs()) / PLAYOUT_CORRECTION_MS;
576
+ ceiling = MAX_UNITS_PER_FRAME;
577
+ }
578
+
579
+ // A rate of a few characters per frame does not survive being rounded down
580
+ // every frame, so the remainder is carried rather than discarded.
581
+ const want = Math.max(0, perMs) * elapsed + stream.revealDebt;
582
+ const units = Math.max(0, Math.min(ceiling, Math.floor(want)));
583
+ stream.revealDebt = Math.min(want, ceiling) - units;
584
+ return units;
585
+ }
586
+
587
+ /// Reveal up to `maxUnits` more of one block, returning how many characters
588
+ /// that turned out to be.
589
+ ///
590
+ /// Returns 0 when the block cannot advance without showing something that is
591
+ /// only meaningful whole: half a fence delimiter opens a code block for one
592
+ /// frame and closes it on the next, and half a table row widens a column that
593
+ /// the following frame widens again.
594
+ function revealFromBlock(block, maxUnits) {
595
+ const pending = block.source.slice(block.revealed);
596
+ if (pending.length === 0 || maxUnits <= 0) return 0;
597
+
598
+ const scan = block.revealScan || (block.revealScan = newScanState());
599
+ // Level with what is already revealed. Normally it is — the scan is
600
+ // advanced as text is revealed — but a block that was shown whole and then
601
+ // grew starts again from a scan that has seen none of it.
602
+ advanceScanTo(scan, block.source, block.revealed);
603
+ const firstNewline = pending.indexOf('\n');
604
+ const firstLineEnd = firstNewline < 0 ? pending.length : firstNewline;
605
+ const revealedText = block.source.slice(0, block.revealed);
606
+ const firstLine = currentLinePrefix(revealedText) + pending.slice(0, firstLineEnd);
607
+
608
+ let count;
609
+ if (
610
+ fencePrefixKind(firstLine, scan) !== 'none'
611
+ || isStructuredLine(firstLine, scan)
612
+ ) {
613
+ if (firstNewline < 0) {
614
+ // Still arriving. Hold it back — unless nothing more is coming, in
615
+ // which case a partial line is all this line will ever be.
616
+ if (!block.complete) return 0;
617
+ count = pending.length;
618
+ } else {
619
+ // The first whole line always goes, however small the frame's budget:
620
+ // holding it back for a budget that a single line already exceeds would
621
+ // stall the reveal outright. Further whole lines follow if the budget
622
+ // covers them, so a burst still catches up.
623
+ count = firstNewline + 1;
624
+ const budgetEnd = graphemeSafeCut(pending, maxUnits);
625
+ if (budgetEnd > count) {
626
+ const lastNewline = pending.lastIndexOf('\n', budgetEnd - 1);
627
+ if (lastNewline + 1 > count) count = lastNewline + 1;
628
+ }
629
+ }
630
+ } else {
631
+ count = graphemeSafeCut(pending, maxUnits);
632
+
633
+ // Do not cross into a line that has to arrive whole — a fence delimiter,
634
+ // a table row, the opening of a formula. Stopping at the newline before
635
+ // it lets the next frame reveal that line atomically.
636
+ let searchFrom = 0;
637
+ while (searchFrom < count - 1) {
638
+ const newlineAt = pending.indexOf('\n', searchFrom);
639
+ if (newlineAt < 0 || newlineAt >= count - 1) break;
640
+ const nextNewline = pending.indexOf('\n', newlineAt + 1);
641
+ const nextLineEnd = nextNewline < 0 ? pending.length : nextNewline;
642
+ const nextLine = pending.slice(newlineAt + 1, nextLineEnd);
643
+ if (
644
+ fencePrefixKind(nextLine, scan) !== 'none'
645
+ || isStructuredLine(nextLine, scan)
646
+ ) {
647
+ count = newlineAt + 1;
648
+ break;
649
+ }
650
+ searchFrom = newlineAt + 1;
651
+ }
652
+ }
653
+
654
+ if (count <= 0) return 0;
655
+ // A line that ended is a unit that ended: the next run of fading text
656
+ // starts fresh rather than blending into the item above it.
657
+ if (pending.lastIndexOf('\n', count - 1) >= 0) block.fadeBreak = true;
658
+ block.revealed += count;
659
+ advanceScanTo(scan, block.source, block.revealed);
660
+ block.dirty = true;
661
+ return count;
662
+ }
663
+
664
+ /// Spend the frame's budget on the typewriter, in document order.
665
+ ///
666
+ /// Atomic blocks are stepped over rather than typed: they appear whole, on
667
+ /// their own clock, and the prose after one does not queue behind it.
668
+ function advanceLane(stream, units) {
669
+ let remaining = units;
670
+ const blocks = stream.blocks;
671
+ let index = stream.laneCursor;
672
+
673
+ while (index < blocks.length) {
674
+ const block = blocks[index];
675
+ if (block.atomic) {
676
+ // An unfinished atomic block is the last thing in the stream by
677
+ // definition, so there is nothing past it to type.
678
+ if (!block.complete) break;
679
+ index += 1;
680
+ continue;
681
+ }
682
+ if (block.revealed >= block.source.length) {
683
+ if (!block.complete) break;
684
+ index += 1;
685
+ continue;
686
+ }
687
+ if (remaining <= 0) break;
688
+ const taken = revealFromBlock(block, remaining);
689
+ if (taken <= 0) break;
690
+ remaining -= taken;
691
+ }
692
+
693
+ stream.laneCursor = index;
694
+ return units - remaining;
695
+ }
696
+
697
+ /// How much of an atomic block may go on screen now.
698
+ ///
699
+ /// Whole, once it is whole. Before that, a table past its delimiter row may
700
+ /// show the rows that have certainly ended: a row is a complete unit, and the
701
+ /// alternative is an empty space where the table will be for as long as the
702
+ /// model takes to write it. Anything else atomic — a display formula, half of
703
+ /// which is a stray dollar sign — shows nothing until it is done.
704
+ function releasableLength(block) {
705
+ if (block.complete) return block.source.length;
706
+ if (!block.rowwise) return 0;
707
+ return block.source.lastIndexOf('\n') + 1;
708
+ }
709
+
710
+ /// Show every atomic block that has arrived, whole or by the row.
711
+ ///
712
+ /// This is the half of the reveal that does not wait for the typewriter, and
713
+ /// it is what produces the effect of a formula or a table landing while the
714
+ /// paragraph introducing it is still being written.
715
+ function releaseAtomicBlocks(stream, now) {
716
+ let released = false;
717
+ const blocks = stream.blocks;
718
+
719
+ for (let index = 0; index < blocks.length; index += 1) {
720
+ const block = blocks[index];
721
+ if (!block.atomic) continue;
722
+ const releasable = releasableLength(block);
723
+ if (releasable <= block.revealed) continue;
724
+ // Never ahead of the block before it. A table that appeared before the
725
+ // sentence introducing it had even started would read as arriving out of
726
+ // order; once that sentence is under way, the table does not wait for it
727
+ // to finish.
728
+ if (index > 0 && blocks[index - 1].revealed <= 0) continue;
729
+
730
+ // The fade belongs to the block arriving, not to it growing: running it
731
+ // again for every row would flash the rows already on screen.
732
+ if (block.revealed <= 0) {
733
+ block.entering = true;
734
+ block.enteredAt = now;
735
+ }
736
+ block.revealed = releasable;
737
+ block.dirty = true;
738
+ released = true;
739
+ }
740
+
741
+ return released;
742
+ }
743
+
744
+ /// Put everything on screen at once, for the paths that have no reveal to
745
+ /// pace: a finished message being re-rendered, or a host that asked for
746
+ /// immediate presentation.
747
+ function revealEverything(stream) {
748
+ for (let index = 0; index < stream.blocks.length; index += 1) {
749
+ const block = stream.blocks[index];
750
+ if (block.revealed >= block.source.length) continue;
751
+ block.revealed = block.source.length;
752
+ block.revealScan = null;
753
+ block.dirty = true;
754
+ block.fadeRuns.length = 0;
755
+ block.entering = false;
756
+ }
757
+ // The last block may still grow, and the cursor names the first block the
758
+ // typewriter has not finished — not the first block with nothing left on
759
+ // screen. Parking it past an open block would leave the text that block
760
+ // grows by permanently unreachable.
761
+ const last = stream.blocks.length - 1;
762
+ stream.laneCursor = last >= 0 && !stream.blocks[last].complete
763
+ ? last
764
+ : stream.blocks.length;
765
+ stream.instant = false;
766
+ }
767
+
768
+ function revealedTotal(stream) {
769
+ let total = 0;
770
+ for (let index = 0; index < stream.blocks.length; index += 1) {
771
+ total += stream.blocks[index].revealed;
772
+ }
773
+ return total;
774
+ }
775
+
776
+ /// Text the host has handed over that the reader has not seen yet. Measured
777
+ /// against `received` rather than against the blocks, because text that
778
+ /// arrived since the last flush has not been split into blocks yet and is
779
+ /// every bit as unrevealed.
780
+ function hasUnrevealed(stream) {
781
+ return revealedTotal(stream) < stream.received.length;
782
+ }
783
+
784
+ function injectChatStyles() {
785
+ if (!documentRef || documentRef.getElementById('md-chat-runtime-style')) return;
786
+ const style = documentRef.createElement('style');
787
+ style.id = 'md-chat-runtime-style';
788
+ style.textContent = `
789
+ #markdown-content.md-chat-list {
790
+ display: flex;
791
+ flex-direction: column;
792
+ gap: var(--md-chat-message-spacing, ${runtimeConfig.chat.messageSpacing});
793
+ max-width: 100%;
794
+ overflow-x: hidden;
795
+ }
796
+ #markdown-content.md-chat-list,
797
+ #markdown-content.md-chat-list * {
798
+ box-sizing: border-box;
799
+ }
800
+ .md-chat-message {
801
+ max-width: var(--md-chat-message-max-width, ${runtimeConfig.chat.maxMessageWidth});
802
+ color: var(--chat-text-color);
803
+ }
804
+ /* Empty on purpose: it is the room the newest question needs in order to reach
805
+ the top of the view. See ensureTailRoom in this file. */
806
+ .md-chat-tail-room {
807
+ flex: 0 0 auto;
808
+ pointer-events: none;
809
+ }
810
+ .md-chat-message-content {
811
+ max-width: 100%;
812
+ overflow-x: hidden;
813
+ word-wrap: break-word;
814
+ }
815
+ /* One top-level markdown block. Transparent to layout, so a message made of
816
+ a dozen of them lays out exactly as the same markdown rendered in one go —
817
+ the split exists so that each block can be revealed, repainted and frozen on
818
+ its own, and it must not cost a box to do it. */
819
+ .md-chat-block {
820
+ display: contents;
821
+ }
822
+ /* Which block carries the bubble's outer margins moves as the message grows,
823
+ and a block with no layout box of its own cannot carry them. The runtime
824
+ marks the two ends instead; see syncChannelEdges in this file. */
825
+ .md-chat-block[data-md-first] > :first-child {
826
+ margin-top: 0;
827
+ }
828
+ .md-chat-block[data-md-last] > :last-child {
829
+ margin-bottom: 0;
830
+ }
831
+ /* Text that has just been revealed comes up from transparent rather than
832
+ snapping in. It hides the sub-pixel reflow of a line growing a character at
833
+ a time, and it gives the reveal a leading edge to follow — the difference
834
+ between text being typed and text being pasted. The wrapper is rebuilt every
835
+ frame with a negative delay, so the animation resumes rather than restarts;
836
+ see paintFadeRun in this file. */
837
+ [data-md-fade] {
838
+ animation: md-chat-fade-in ${FADE_MS}ms linear both;
839
+ }
840
+ @keyframes md-chat-fade-in {
841
+ /* Not from zero: the newest run would be invisible for a tenth of a second,
842
+ which reads as the answer lagging rather than as it appearing. */
843
+ from { opacity: 0.15; }
844
+ to { opacity: 1; }
845
+ }
846
+ @media (prefers-reduced-motion: reduce) {
847
+ [data-md-fade] { animation: none; }
848
+ }
849
+ /* A whole block arriving at once: a table, a display formula — anything whose
850
+ shape only exists complete. It comes up from transparent for the same reason
851
+ the typed text does, except that here the unit is the block rather than the
852
+ run: there is no leading edge to follow, because every part of it appeared
853
+ at the same moment. */
854
+ [data-md-runtime-enter] {
855
+ animation: md-chat-block-in ${BLOCK_ENTER_MS}ms ease-out both;
856
+ }
857
+ @keyframes md-chat-block-in {
858
+ from { opacity: 0; }
859
+ to { opacity: 1; }
860
+ }
861
+ @media (prefers-reduced-motion: reduce) {
862
+ [data-md-runtime-enter] { animation: none; }
863
+ }
864
+ /* Tables stream row by row, and the columns are left to automatic layout while
865
+ they do. Freezing them instead — a fixed table layout, which is what stood
866
+ here — ignores the minimum column width, so the table was written in one
867
+ shape and snapped into another the moment it finished. Settling as the rows
868
+ arrive is the smaller movement of the two, and it ends where the table was
869
+ already going. */
870
+ .md-chat-reasoning {
871
+ margin: 0 0 10px 0;
872
+ max-width: 100%;
873
+ }
874
+ .md-chat-reasoning + .md-chat-message-content[data-empty="true"] {
875
+ min-height: 0;
876
+ }
877
+ .md-chat-message-assistant {
878
+ align-self: center;
879
+ width: 100%;
880
+ margin-left: auto;
881
+ margin-right: auto;
882
+ }
883
+ .md-chat-message-user {
884
+ align-self: flex-end;
885
+ max-width: var(--md-chat-user-message-max-width, ${runtimeConfig.chat.userMessageMaxWidth});
886
+ margin-left: auto;
887
+ margin-right: max(0px, calc((100% - var(--md-chat-message-max-width, ${runtimeConfig.chat.maxMessageWidth})) / 2));
888
+ padding: 10px 12px;
889
+ border-radius: 16px;
890
+ background: var(--chat-fill);
891
+ }
892
+ .md-chat-message-system {
893
+ align-self: center;
894
+ max-width: min(92%, 620px);
895
+ padding: 8px 10px;
896
+ border-radius: 12px;
897
+ color: var(--chat-secondary-text-color);
898
+ background: var(--chat-inline-code-bg);
899
+ font-size: 0.92em;
900
+ }
901
+ .md-chat-message-actions {
902
+ display: flex;
903
+ align-items: center;
904
+ gap: 2px;
905
+ min-height: 40px;
906
+ margin-top: 4px;
907
+ color: var(--chat-secondary-text-color);
908
+ }
909
+ .md-chat-message-user .md-chat-message-actions {
910
+ justify-content: flex-end;
911
+ }
912
+ .md-chat-message[data-status="pending"] .md-chat-message-actions,
913
+ .md-chat-message[data-status="streaming"] .md-chat-message-actions,
914
+ .md-chat-message-actions:empty {
915
+ display: none;
916
+ }
917
+ .md-chat-message-action {
918
+ display: inline-flex;
919
+ align-items: center;
920
+ justify-content: center;
921
+ width: 40px;
922
+ height: 40px;
923
+ margin: 0;
924
+ padding: 0;
925
+ border: 0;
926
+ border-radius: 8px;
927
+ color: inherit;
928
+ background: transparent;
929
+ cursor: pointer;
930
+ -webkit-tap-highlight-color: transparent;
931
+ }
932
+ .md-chat-message-action:hover,
933
+ .md-chat-message-action:focus-visible {
934
+ color: var(--chat-text-color);
935
+ background: var(--chat-inline-code-bg);
936
+ outline: none;
937
+ }
938
+ .md-chat-message-action:active {
939
+ transform: scale(0.94);
940
+ }
941
+ .md-chat-message-action svg {
942
+ width: 17px;
943
+ height: 17px;
944
+ fill: none;
945
+ stroke: currentColor;
946
+ stroke-width: 2;
947
+ stroke-linecap: round;
948
+ stroke-linejoin: round;
949
+ }
950
+ .md-chat-message-action .md-chat-icon-check {
951
+ display: none;
952
+ }
953
+ .md-chat-message-action.is-complete .md-chat-icon-default {
954
+ display: none;
955
+ }
956
+ .md-chat-message-action.is-complete .md-chat-icon-check {
957
+ display: block;
958
+ color: var(--chat-success);
959
+ }
960
+ .md-chat-message[data-status="pending"] .md-chat-message-content[data-empty="true"]::before,
961
+ .md-chat-message[data-status="streaming"] .md-chat-message-content[data-empty="true"]::before {
962
+ content: '';
963
+ display: inline-block;
964
+ width: 36px;
965
+ height: 12px;
966
+ border-radius: 999px;
967
+ background: linear-gradient(90deg, var(--chat-fill), var(--chat-inline-code-bg), var(--chat-fill));
968
+ }
969
+ /* The thinking fold is itself the "working on it" signal, so the placeholder
970
+ pill under it would be a second one saying the same thing. Declared after the
971
+ pill so it wins on equal specificity. */
972
+ .md-chat-message[data-reasoning="thinking"] .md-chat-message-content[data-empty="true"]::before {
973
+ content: none;
974
+ }
975
+ .md-chat-message[data-status="failed"] {
976
+ color: var(--md-error-color);
977
+ }
978
+ `;
979
+ (documentRef.head || documentRef.documentElement).appendChild(style);
980
+ }
981
+
982
+ function ensureMounted() {
983
+ if (state.mounted && state.container) return true;
984
+ if (!documentRef) {
985
+ notifyError(new Error('Document is not available'), 'chat');
986
+ return false;
987
+ }
988
+ if (!renderLib || typeof renderLib.renderMarkdown !== 'function') {
989
+ notifyError(new Error('MarkdownRender library missing'), 'chat');
990
+ return false;
991
+ }
992
+
993
+ const container = documentRef.getElementById('markdown-content');
994
+ if (!container) {
995
+ notifyError(new Error('Markdown container not found'), 'chat');
996
+ return false;
997
+ }
998
+
999
+ injectChatStyles();
1000
+ container.innerHTML = '';
1001
+ container.classList.add('md-chat-list');
1002
+ container.setAttribute('data-md-runtime', 'chat');
1003
+ container.setAttribute('role', 'log');
1004
+ container.setAttribute('aria-live', 'polite');
1005
+
1006
+ state.container = container;
1007
+ state.mounted = true;
1008
+ state.messages.clear();
1009
+ state.order = [];
1010
+ installViewportListeners();
1011
+ return true;
1012
+ }
1013
+
1014
+ function normalizeRole(role) {
1015
+ return MESSAGE_ROLES.has(role) ? role : 'assistant';
1016
+ }
1017
+
1018
+ function normalizeStatus(status) {
1019
+ return MESSAGE_STATUSES.has(status) ? status : 'completed';
1020
+ }
1021
+
1022
+ function normalizeMessage(message) {
1023
+ const source = message || {};
1024
+ const id = source.id === undefined || source.id === null
1025
+ ? `message-${Date.now()}-${Math.random().toString(36).slice(2)}`
1026
+ : String(source.id);
1027
+ const markdown = typeof source.markdown === 'string'
1028
+ ? source.markdown
1029
+ : (typeof source.content === 'string' ? source.content : '');
1030
+ const reasoning = typeof source.reasoning === 'string'
1031
+ ? source.reasoning
1032
+ : (typeof source.reasoningContent === 'string' ? source.reasoningContent : '');
1033
+
1034
+ return {
1035
+ id,
1036
+ role: normalizeRole(source.role),
1037
+ markdown,
1038
+ reasoning,
1039
+ status: normalizeStatus(source.status)
1040
+ };
1041
+ }
1042
+
1043
+ function dispatchChatEvent(name, detail) {
1044
+ if (!documentRef || typeof global.CustomEvent !== 'function') return;
1045
+ documentRef.dispatchEvent(new global.CustomEvent(name, { detail: detail || {} }));
1046
+ }
1047
+
1048
+ function svgElement(className) {
1049
+ const node = documentRef.createElementNS('http://www.w3.org/2000/svg', 'svg');
1050
+ node.setAttribute('class', className || '');
1051
+ node.setAttribute('viewBox', '0 0 24 24');
1052
+ node.setAttribute('aria-hidden', 'true');
1053
+ return node;
1054
+ }
1055
+
1056
+ function svgChild(name, attributes) {
1057
+ const node = documentRef.createElementNS('http://www.w3.org/2000/svg', name);
1058
+ Object.keys(attributes).forEach((key) => node.setAttribute(key, attributes[key]));
1059
+ return node;
1060
+ }
1061
+
1062
+ function actionIcon(action) {
1063
+ const icon = svgElement('md-chat-icon-default');
1064
+ if (action === 'copy') {
1065
+ icon.appendChild(svgChild('rect', { x: '9', y: '9', width: '13', height: '13', rx: '2', ry: '2' }));
1066
+ icon.appendChild(svgChild('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }));
1067
+ } else if (action === 'retry') {
1068
+ icon.appendChild(svgChild('path', { d: 'M3 12a9 9 0 1 0 3-6.7L3 8' }));
1069
+ icon.appendChild(svgChild('path', { d: 'M3 3v5h5' }));
1070
+ } else if (action === 'edit') {
1071
+ icon.appendChild(svgChild('path', { d: 'M12 20h9' }));
1072
+ icon.appendChild(svgChild('path', { d: 'M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z' }));
1073
+ }
1074
+ return icon;
1075
+ }
1076
+
1077
+ function checkIcon() {
1078
+ const icon = svgElement('md-chat-icon-check');
1079
+ icon.appendChild(svgChild('path', { d: 'm5 12 4 4L19 6' }));
1080
+ return icon;
1081
+ }
1082
+
1083
+ function fallbackCopy(text) {
1084
+ return new Promise((resolve, reject) => {
1085
+ try {
1086
+ const textarea = documentRef.createElement('textarea');
1087
+ textarea.value = text;
1088
+ textarea.setAttribute('readonly', '');
1089
+ textarea.style.position = 'fixed';
1090
+ textarea.style.top = '-9999px';
1091
+ textarea.style.opacity = '0';
1092
+ documentRef.body.appendChild(textarea);
1093
+ textarea.focus();
1094
+ textarea.select();
1095
+ const succeeded = documentRef.execCommand('copy');
1096
+ textarea.remove();
1097
+ succeeded ? resolve() : reject(new Error('Copy failed'));
1098
+ } catch (error) {
1099
+ reject(error);
1100
+ }
1101
+ });
1102
+ }
1103
+
1104
+ function copyMessageText(text) {
1105
+ if (!text) return Promise.reject(new Error('Nothing to copy'));
1106
+ const clipboard = global.navigator && global.navigator.clipboard;
1107
+ if (clipboard && typeof clipboard.writeText === 'function') {
1108
+ return clipboard.writeText(text).catch(() => fallbackCopy(text));
1109
+ }
1110
+ return fallbackCopy(text);
1111
+ }
1112
+
1113
+ function configuredActions(role) {
1114
+ const config = runtimeConfig.chat.messageActions;
1115
+ if (!config.enabled || role === 'system') return [];
1116
+ const source = role === 'user' ? config.user : config.assistant;
1117
+ return (Array.isArray(source) ? source : [])
1118
+ .map(String)
1119
+ .filter((action) => action === 'copy' || action === 'retry' || action === 'edit');
1120
+ }
1121
+
1122
+ function emitMessageAction(entry, action, succeeded, error) {
1123
+ dispatchChatEvent('markdown-chat-action', {
1124
+ messageId: entry.message.id,
1125
+ action,
1126
+ succeeded: succeeded !== false,
1127
+ error: error ? String(error.message || error) : ''
1128
+ });
1129
+ }
1130
+
1131
+ function runMessageAction(entry, action, button) {
1132
+ if (action !== 'copy') {
1133
+ emitMessageAction(entry, action, true, null);
1134
+ return;
1135
+ }
1136
+
1137
+ copyMessageText(answerText(entry))
1138
+ .then(() => {
1139
+ const labels = runtimeConfig.chat.messageActions.labels;
1140
+ button.classList.add('is-complete');
1141
+ button.setAttribute('aria-label', labels.copied);
1142
+ button.setAttribute('title', labels.copied);
1143
+ emitMessageAction(entry, action, true, null);
1144
+ global.setTimeout(() => {
1145
+ button.classList.remove('is-complete');
1146
+ button.setAttribute('aria-label', labels.copy);
1147
+ button.setAttribute('title', labels.copy);
1148
+ }, 1600);
1149
+ })
1150
+ .catch((error) => emitMessageAction(entry, action, false, error));
1151
+ }
1152
+
1153
+ function createActionButton(entry, action) {
1154
+ const labels = runtimeConfig.chat.messageActions.labels;
1155
+ const button = documentRef.createElement('button');
1156
+ button.type = 'button';
1157
+ button.className = 'md-chat-message-action';
1158
+ button.setAttribute('data-chat-action', action);
1159
+ button.setAttribute('aria-label', labels[action] || action);
1160
+ button.setAttribute('title', labels[action] || action);
1161
+ button.appendChild(actionIcon(action));
1162
+ if (action === 'copy') button.appendChild(checkIcon());
1163
+ button.addEventListener('click', (event) => {
1164
+ event.preventDefault();
1165
+ event.stopPropagation();
1166
+ runMessageAction(entry, action, button);
1167
+ });
1168
+ return button;
1169
+ }
1170
+
1171
+ function syncActionBar(entry) {
1172
+ if (!entry.actions) return;
1173
+ const actions = configuredActions(entry.message.role);
1174
+ const signature = actions.join(',');
1175
+ if (entry.actionSignature === signature) return;
1176
+ entry.actionSignature = signature;
1177
+ entry.actions.innerHTML = '';
1178
+ actions.forEach((action) => entry.actions.appendChild(createActionButton(entry, action)));
1179
+ }
1180
+
1181
+ /// Label and tag settings for the markdown-level `<think>` fold. Inline
1182
+ /// extraction normally strips the markers long before the renderer sees them,
1183
+ /// but with extraction off — or for a nested block inside reasoning — the
1184
+ /// fold markdown-it produces should still read the same as the streamed one.
1185
+ const markdownReasoningConfig = {
1186
+ enabled: runtimeConfig.chat.reasoning.enabled !== false,
1187
+ tags: runtimeConfig.chat.reasoning.tags,
1188
+ labels: runtimeConfig.chat.reasoning.labels,
1189
+ open: Boolean(runtimeConfig.chat.reasoning.defaultExpanded)
1190
+ };
1191
+
1192
+ function markdownOptions(deferCodeHighlight, deferMathErrors) {
1193
+ const options = Object.assign({}, runtimeConfig.markdown || {});
1194
+ if (runtimeConfig.extensions) {
1195
+ options.extensions = runtimeConfig.extensions;
1196
+ }
1197
+ // Code blocks inside a message get their copy button from the same render
1198
+ // pipeline as a standalone document, so it needs the same strings.
1199
+ options.i18n = runtimeConfig.i18n || {};
1200
+ if (!options.reasoning) {
1201
+ options.reasoning = markdownReasoningConfig;
1202
+ }
1203
+ if (deferCodeHighlight) {
1204
+ options.deferCodeHighlight = true;
1205
+ }
1206
+ // A formula still arriving is not a broken formula. Suppress KaTeX's error
1207
+ // red until the message completes, so an expression does not flash red and
1208
+ // then quietly turn correct once its closing brace shows up.
1209
+ if (deferMathErrors) {
1210
+ options.deferMathErrors = true;
1211
+ }
1212
+ return options;
1213
+ }
1214
+
1215
+ function renderSegment(text, options) {
1216
+ if (!text) return '';
1217
+ try {
1218
+ // Rendering straight to DOM nodes skips serializing the message to HTML
1219
+ // and parsing it back again on every frame — the largest avoidable cost
1220
+ // in the streaming loop.
1221
+ if (typeof renderLib.renderMarkdownFragment === 'function') {
1222
+ return renderLib.renderMarkdownFragment(text, options);
1223
+ }
1224
+ return renderLib.renderMarkdown(text, options);
1225
+ } catch (err) {
1226
+ console.error('Markdown chat segment render error:', err);
1227
+ notifyError(err, 'chat-render');
1228
+ return '';
1229
+ }
1230
+ }
1231
+
1232
+ function commit(target, content, preserveCodeBlockScroll) {
1233
+ if (!target) return;
1234
+ if (typeof renderLib.applyStreamingHtml === 'function') {
1235
+ renderLib.applyStreamingHtml(target, content, {
1236
+ preserveCodeBlockScroll: preserveCodeBlockScroll !== false
1237
+ });
1238
+ return;
1239
+ }
1240
+ if (typeof content === 'string') {
1241
+ target.innerHTML = content;
1242
+ return;
1243
+ }
1244
+ target.replaceChildren();
1245
+ target.appendChild(asFragment(content));
1246
+ }
1247
+
1248
+ function updateElementMetadata(entry) {
1249
+ const { element, message } = entry;
1250
+ const className = `md-chat-message md-chat-message-${message.role}`;
1251
+ if (element.className !== className) {
1252
+ element.className = className;
1253
+ }
1254
+ setAttr(element, 'data-message-id', message.id);
1255
+ setAttr(element, 'data-role', message.role);
1256
+ setAttr(element, 'data-status', message.status);
1257
+ syncActionBar(entry);
1258
+ }
1259
+
1260
+ function createEntry(message) {
1261
+ const element = documentRef.createElement('article');
1262
+ const content = documentRef.createElement('div');
1263
+ const actions = documentRef.createElement('div');
1264
+ content.className = 'md-chat-message-content';
1265
+ actions.className = 'md-chat-message-actions';
1266
+ actions.setAttribute('role', 'toolbar');
1267
+ actions.setAttribute('aria-label', 'Message actions');
1268
+ element.appendChild(content);
1269
+ element.appendChild(actions);
1270
+
1271
+ const entry = {
1272
+ message,
1273
+ element,
1274
+ content,
1275
+ actions,
1276
+ actionSignature: null,
1277
+ answer: newChannel('answer', content, ''),
1278
+ // Both created on first sight of reasoning: most messages never have any,
1279
+ // and an empty fold in every bubble is a node per message for nothing.
1280
+ reasoning: null,
1281
+ reasoningView: null,
1282
+ splitter: null,
1283
+ finalRequested: false
1284
+ };
1285
+ updateElementMetadata(entry);
1286
+ return entry;
1287
+ }
1288
+
1289
+ /// Text the reader would expect a copy to produce: the answer, without the
1290
+ /// chain of thought that was folded away above it.
1291
+ function answerText(entry) {
1292
+ return entry.answer.stream.received;
1293
+ }
1294
+
1295
+ function hasPendingText(entry) {
1296
+ if (hasUnrevealed(entry.answer.stream)) return true;
1297
+ return Boolean(entry.reasoning) && hasUnrevealed(entry.reasoning.stream);
1298
+ }
1299
+
1300
+ // -------------------------------------------------------------------------
1301
+ // Reasoning fold
1302
+ // -------------------------------------------------------------------------
1303
+
1304
+ function reasoningChevron() {
1305
+ const icon = documentRef.createElementNS('http://www.w3.org/2000/svg', 'svg');
1306
+ icon.setAttribute('class', 'md-reasoning-chevron');
1307
+ icon.setAttribute('viewBox', '0 0 24 24');
1308
+ icon.setAttribute('aria-hidden', 'true');
1309
+ icon.appendChild(svgChild('path', {
1310
+ d: 'm9 6 6 6-6 6',
1311
+ fill: 'none',
1312
+ stroke: 'currentColor',
1313
+ 'stroke-width': '2',
1314
+ 'stroke-linecap': 'round',
1315
+ 'stroke-linejoin': 'round'
1316
+ }));
1317
+ return icon;
1318
+ }
1319
+
1320
+ function formatReasoningLabel(view) {
1321
+ const labels = reasoningConfig().labels;
1322
+ if (view.state === 'thinking') return labels.thinking;
1323
+
1324
+ const elapsed = view.startedAt && view.endedAt ? view.endedAt - view.startedAt : 0;
1325
+ const seconds = Math.round(elapsed / 1000);
1326
+ // Under a second there is no duration worth reporting, and "Thought for 0s"
1327
+ // reads as a failure rather than as speed.
1328
+ if (seconds < 1 || !labels.duration) return labels.done;
1329
+ return String(labels.duration).replace('{seconds}', String(seconds));
1330
+ }
1331
+
1332
+ function syncReasoningHeader(entry) {
1333
+ const view = entry.reasoningView;
1334
+ if (!view) return;
1335
+ const text = formatReasoningLabel(view);
1336
+ if (view.labelText !== text) {
1337
+ view.labelText = text;
1338
+ view.label.textContent = text;
1339
+ }
1340
+ setAttr(view.panel, 'data-state', view.state);
1341
+ setAttr(entry.element, 'data-reasoning', view.state);
1342
+ }
1343
+
1344
+ function setReasoningExpanded(entry, expanded) {
1345
+ const view = entry.reasoningView;
1346
+ if (!view || view.expanded === expanded) return;
1347
+ view.expanded = expanded;
1348
+ setAttr(view.panel, 'data-expanded', expanded ? 'true' : 'false');
1349
+ view.header.setAttribute('aria-expanded', expanded ? 'true' : 'false');
1350
+ }
1351
+
1352
+ /// Move the fold between its two states.
1353
+ ///
1354
+ /// `thinking` keeps it open — the reasoning is the only thing on screen while
1355
+ /// the answer has not started, so collapsing it would leave the reader
1356
+ /// watching an empty bubble. `done` folds it away as soon as the answer takes
1357
+ /// over, unless the reader has already made that choice themselves.
1358
+ function setReasoningState(entry, next) {
1359
+ const view = entry.reasoningView;
1360
+ if (!view || view.state === next) return;
1361
+ const now = Date.now();
1362
+
1363
+ if (next === 'thinking') {
1364
+ view.state = 'thinking';
1365
+ if (!view.startedAt) view.startedAt = now;
1366
+ view.endedAt = 0;
1367
+ if (!view.userToggled) setReasoningExpanded(entry, true);
1368
+ } else {
1369
+ view.state = 'done';
1370
+ view.endedAt = now;
1371
+ if (!view.userToggled && reasoningConfig().autoCollapse !== false) {
1372
+ setReasoningExpanded(entry, false);
1373
+ }
1374
+ }
1375
+ syncReasoningHeader(entry);
1376
+ }
1377
+
1378
+ function markReasoningDone(entry) {
1379
+ if (entry.reasoningView && entry.reasoningView.state === 'thinking') {
1380
+ setReasoningState(entry, 'done');
1381
+ }
1382
+ }
1383
+
1384
+ function ensureReasoningView(entry) {
1385
+ if (entry.reasoningView) return entry.reasoningView;
1386
+
1387
+ const panel = documentRef.createElement('div');
1388
+ panel.className = 'md-reasoning md-chat-reasoning';
1389
+ panel.setAttribute('data-md-reasoning', 'chat');
1390
+ panel.setAttribute('data-expanded', 'false');
1391
+
1392
+ const header = documentRef.createElement('button');
1393
+ header.type = 'button';
1394
+ header.className = 'md-reasoning-header';
1395
+ header.setAttribute('aria-expanded', 'false');
1396
+ const label = documentRef.createElement('span');
1397
+ label.className = 'md-reasoning-label';
1398
+ header.appendChild(reasoningChevron());
1399
+ header.appendChild(label);
1400
+
1401
+ const collapsible = documentRef.createElement('div');
1402
+ collapsible.className = 'md-reasoning-collapsible';
1403
+ const body = documentRef.createElement('div');
1404
+ body.className = 'md-reasoning-body';
1405
+ collapsible.appendChild(body);
1406
+
1407
+ panel.appendChild(header);
1408
+ panel.appendChild(collapsible);
1409
+ entry.element.insertBefore(panel, entry.content);
1410
+
1411
+ const view = {
1412
+ panel,
1413
+ header,
1414
+ label,
1415
+ collapsible,
1416
+ body,
1417
+ state: 'idle',
1418
+ expanded: false,
1419
+ // A reader who opened or closed the fold has said what they want; auto
1420
+ // collapse must not overrule that for the rest of the message.
1421
+ userToggled: false,
1422
+ startedAt: 0,
1423
+ endedAt: 0,
1424
+ labelText: ''
1425
+ };
1426
+ entry.reasoningView = view;
1427
+ entry.reasoning = newChannel('reasoning', body, '');
1428
+
1429
+ header.addEventListener('click', (event) => {
1430
+ event.preventDefault();
1431
+ event.stopPropagation();
1432
+ view.userToggled = true;
1433
+ setReasoningExpanded(entry, !view.expanded);
1434
+ // No auto-scroll here. The reader asked for this, and yanking the view to
1435
+ // the bottom would carry them away from the fold they just opened.
1436
+ scheduleHeight('chat-reasoning-toggle', false);
1437
+ dispatchChatEvent('markdown-chat-reasoning', {
1438
+ messageId: entry.message.id,
1439
+ expanded: view.expanded
1440
+ });
1441
+ });
1442
+
1443
+ return view;
1444
+ }
1445
+
1446
+ function removeReasoningView(entry) {
1447
+ if (!entry.reasoningView) return;
1448
+ if (entry.reasoning) cancelFrame(entry.reasoning.stream.pendingFrame);
1449
+ entry.reasoningView.panel.remove();
1450
+ entry.reasoningView = null;
1451
+ entry.reasoning = null;
1452
+ entry.element.removeAttribute('data-reasoning');
1453
+ }
1454
+
1455
+ function consumeLine(scanState, line, newlineOffset) {
1456
+ const trimmed = line.trim();
1457
+ let closedBlock = false;
1458
+
1459
+ if (!scanState.inMath) {
1460
+ if (!scanState.inFence) {
1461
+ const fenceMark = openingFenceMark(line);
1462
+ if (fenceMark) {
1463
+ scanState.inFence = true;
1464
+ scanState.fenceMark = fenceMark;
1465
+ // Only a fence that opens at the left margin is a top-level block.
1466
+ // An indented one belongs to a list item, and cutting the document
1467
+ // after it would restart the list.
1468
+ scanState.fenceAtMargin = line === line.trimStart();
1469
+ }
1470
+ } else if (isClosingFenceLine(line, scanState.fenceMark)) {
1471
+ scanState.inFence = false;
1472
+ scanState.fenceMark = '';
1473
+ closedBlock = scanState.fenceAtMargin && line === line.trimStart();
1474
+ scanState.fenceAtMargin = false;
1475
+ }
1476
+ }
1477
+
1478
+ if (!scanState.inFence && trimmed === '$$') {
1479
+ scanState.inMath = !scanState.inMath;
1480
+ if (!scanState.inMath && line === '$$') {
1481
+ closedBlock = true;
1482
+ }
1483
+ }
1484
+
1485
+ if (!scanState.inFence && !scanState.inMath) {
1486
+ // A blank line ends the block above it — and so does the closing
1487
+ // delimiter of a fenced code block or a display formula. Nothing that
1488
+ // arrives later can change how the text before such a delimiter parses,
1489
+ // so each is a point at which the document can be cut into two pieces
1490
+ // that render exactly as the whole would have. Those cuts are the blocks
1491
+ // the reveal engine works in.
1492
+ const endsBlock = closedBlock
1493
+ || (trimmed === ''
1494
+ && scanState.prevNonBlankLine !== null
1495
+ && !LIST_LIKE.test(scanState.prevNonBlankLine));
1496
+ if (endsBlock) {
1497
+ scanState.lastSafe = newlineOffset + 1;
1498
+ scanState.boundaries.push(scanState.lastSafe);
1499
+ }
1500
+ }
1501
+
1502
+ if (trimmed !== '') {
1503
+ scanState.prevNonBlankLine = trimmed;
1504
+ }
1505
+ }
1506
+
1507
+ /// Advance a scan over `text` up to `end`, consuming whole lines only.
1508
+ function advanceScanTo(scanState, text, end) {
1509
+ let i = scanState.cursor;
1510
+ while (i < end) {
1511
+ if (text.charCodeAt(i) === 0x0a) {
1512
+ consumeLine(scanState, text.slice(scanState.lineStart, i), i);
1513
+ scanState.lineStart = i + 1;
1514
+ }
1515
+ i += 1;
1516
+ }
1517
+ scanState.cursor = i;
1518
+ }
1519
+
1520
+ /// Split everything received so far into blocks.
1521
+ ///
1522
+ /// The open block at the end is rewritten on every call, because it is still
1523
+ /// growing; every block before it was closed at a boundary and is never
1524
+ /// touched again.
1525
+ function ingest(stream) {
1526
+ const scan = stream.scanState;
1527
+ advanceScanTo(scan, stream.received, stream.received.length);
1528
+
1529
+ const blocks = stream.blocks;
1530
+ if (blocks.length === 0) blocks.push(newBlock(0));
1531
+
1532
+ while (scan.boundariesTaken < scan.boundaries.length) {
1533
+ const end = scan.boundaries[scan.boundariesTaken];
1534
+ scan.boundariesTaken += 1;
1535
+ const open = blocks[blocks.length - 1];
1536
+ if (end <= open.start) continue;
1537
+ open.source = stream.received.slice(open.start, end);
1538
+ open.complete = true;
1539
+ classifyBlock(open);
1540
+ blocks.push(newBlock(end));
1541
+ }
1542
+
1543
+ const open = blocks[blocks.length - 1];
1544
+ const source = stream.received.slice(open.start);
1545
+ if (source !== open.source) {
1546
+ open.source = source;
1547
+ classifyBlock(open);
1548
+ }
1549
+ // Nothing more is coming, so whatever the last block has is all of it.
1550
+ if (stream.ended && !open.complete) {
1551
+ open.complete = true;
1552
+ classifyBlock(open);
1553
+ }
1554
+ }
1555
+
1556
+ // -------------------------------------------------------------------------
1557
+ // Painting
1558
+ // -------------------------------------------------------------------------
1559
+
1560
+ /// The longest a streaming block may sit unchanged on screen. Past roughly
1561
+ /// a sixth of a second the typewriter stops reading as motion and starts
1562
+ /// reading as a freeze followed by a jump.
1563
+ const TAIL_REPAINT_MAX_GAP_MS = 150;
1564
+ /// Share of the wall clock a long block may spend repainting itself,
1565
+ /// expressed as a divisor: one repaint every four times its own cost is a
1566
+ /// 25% duty cycle, which leaves the frame budget room for everything else.
1567
+ const TAIL_REPAINT_DUTY = 4;
1568
+ /// The gap cap wins over the duty cycle, but only so far: a repaint that
1569
+ /// costs more than the cap would otherwise be allowed to run back to back.
1570
+ const TAIL_REPAINT_FLOOR_DUTY = 2;
1571
+
1572
+ /// How long a freshly revealed run of text takes to reach full opacity.
1573
+ ///
1574
+ /// Deliberately much longer than the interval at which new runs start, so
1575
+ /// that several are always in flight at once. That overlap is the whole
1576
+ /// effect: the newest text sits at the bottom of a gradient a dozen or more
1577
+ /// characters deep, each step a little more solid than the one below it,
1578
+ /// and the answer reads as developing rather than as arriving in hard
1579
+ /// increments. One run at a time — the obvious implementation — leaves
1580
+ /// everything but the last few characters fully opaque, which is a fade
1581
+ /// nobody can see.
1582
+ const FADE_MS = 600;
1583
+ /// How often a new run starts. FADE_MS / FADE_STEP_MS is the number of steps
1584
+ /// the gradient has.
1585
+ const FADE_STEP_MS = 90;
1586
+ /// A burst can reveal far more than a frame's worth of text. Past this much
1587
+ /// the fade stops being a leading edge and becomes a whole paragraph
1588
+ /// pulsing, so only the newest characters get it.
1589
+ const MAX_FADE_CHARS = 240;
1590
+ const FADE_ATTRIBUTE = 'data-md-fade';
1591
+ /// How long an atomic block takes to materialise. Shorter than the character
1592
+ /// fade: a whole table appearing is a large change, and drawing it out reads
1593
+ /// as the page being slow rather than as the block arriving.
1594
+ const BLOCK_ENTER_MS = 260;
1595
+ /// Written onto the block's own top-level elements rather than a wrapper.
1596
+ /// The `data-md-runtime-` prefix is what stops the next diff from stripping
1597
+ /// it: the tree the diff compares against is rendered from markdown and knows
1598
+ /// nothing about animations.
1599
+ const BLOCK_ENTER_ATTRIBUTE = 'data-md-runtime-enter';
1600
+ const SHOW_TEXT = 4;
1601
+
1602
+ /// Take the fade wrappers back out, restoring the exact node shape the diff
1603
+ /// expects to find.
1604
+ ///
1605
+ /// They are the runtime's own decoration: the tree the diff compares against
1606
+ /// is rendered from markdown and knows nothing about them, so a wrapper left
1607
+ /// in place would put a span where a text node belongs and rebuild the
1608
+ /// paragraph around it on every frame.
1609
+ function clearFadeRuns(root) {
1610
+ if (!root) return;
1611
+ const wrappers = root.querySelectorAll(`[${FADE_ATTRIBUTE}]`);
1612
+ if (wrappers.length === 0) return;
1613
+
1614
+ const parents = new Set();
1615
+ wrappers.forEach((wrapper) => {
1616
+ const parent = wrapper.parentNode;
1617
+ if (!parent) return;
1618
+ while (wrapper.firstChild) {
1619
+ parent.insertBefore(wrapper.firstChild, wrapper);
1620
+ }
1621
+ parent.removeChild(wrapper);
1622
+ parents.add(parent);
1623
+ });
1624
+ // Splitting a text node to wrap its tail leaves two where the render
1625
+ // produced one, and the diff matches children by position: without merging
1626
+ // them back, every later sibling would line up against the wrong node.
1627
+ parents.forEach((parent) => parent.normalize());
1628
+ }
1629
+
1630
+ /// True when the node sits inside a subtree the renderer declared final. A
1631
+ /// settled KaTeX formula is hundreds of spans that exist purely as a
1632
+ /// function of their source, and the diff walks straight past it — a wrapper
1633
+ /// smuggled inside would never be compared, and never taken out again.
1634
+ function isInsideSettledSubtree(node, root) {
1635
+ let current = node.parentNode;
1636
+ while (current && current !== root) {
1637
+ if (current.nodeType === 1 && current.hasAttribute('data-md-sig')) return true;
1638
+ current = current.parentNode;
1639
+ }
1640
+ return false;
1641
+ }
1642
+
1643
+ /// Parents whose children may not be phrasing content. The whitespace
1644
+ /// between two rows of a table is still a text node, and a span smuggled in
1645
+ /// there is invalid markup that the browser is free to move somewhere else.
1646
+ const NON_PHRASING_PARENTS = new Set([
1647
+ 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'UL', 'OL', 'DL', 'COLGROUP', 'SELECT'
1648
+ ]);
1649
+
1650
+ /// Whether this text node is worth wrapping. The newlines markdown leaves
1651
+ /// between block elements are text nodes too, and fading one in fades
1652
+ /// nothing at all while costing a wrapper — and, between table rows, an
1653
+ /// invalid one.
1654
+ function canFadeTextNode(node) {
1655
+ if (node.length === 0) return false;
1656
+ if (!/\S/.test(node.data)) return false;
1657
+ const parent = node.parentNode;
1658
+ if (!parent || parent.nodeType !== 1) return false;
1659
+ return !NON_PHRASING_PARENTS.has(parent.nodeName);
1660
+ }
1661
+
1662
+ function wrapInFade(doc, textNode, elapsed) {
1663
+ const parent = textNode.parentNode;
1664
+ if (!parent) return;
1665
+ const span = doc.createElement('span');
1666
+ span.setAttribute(FADE_ATTRIBUTE, '');
1667
+ // The wrapper is rebuilt on every frame, so its animation would restart on
1668
+ // every frame and never get past its first instant. A negative delay makes
1669
+ // it resume instead: the run picks up where the previous frame left it and
1670
+ // still finishes one duration after the text first appeared.
1671
+ if (elapsed > 0) {
1672
+ span.style.animationDelay = `-${Math.round(Math.min(elapsed, FADE_MS))}ms`;
1673
+ }
1674
+ parent.insertBefore(span, textNode);
1675
+ span.appendChild(textNode);
1676
+ }
1677
+
1678
+ /// Wrap each run in its own span, newest last, so every run carries the
1679
+ /// opacity its own age has earned.
1680
+ ///
1681
+ /// The runs sit end to end at the tail of the block's rendered text, so the
1682
+ /// walk goes backwards from its last character: the newest run first, then
1683
+ /// the one before it, and so on until a run has finished fading. A run may
1684
+ /// span several text nodes — a list item ends in one and starts in another —
1685
+ /// and a text node may span two runs, in which case it is split between them.
1686
+ ///
1687
+ /// Bounded to the one block being typed rather than to the whole message:
1688
+ /// with atomic blocks landing out of order, the newest text on screen is
1689
+ /// routinely *not* the text the typewriter last revealed.
1690
+ function applyFadeRuns(root, runs, now) {
1691
+ if (!root) return;
1692
+ const doc = root.ownerDocument || documentRef;
1693
+ if (runs.length === 0 || typeof doc.createTreeWalker !== 'function') return;
1694
+
1695
+ // Walked from the end rather than collected: the runs only ever cover the
1696
+ // last couple of hundred characters.
1697
+ const walker = doc.createTreeWalker(root, SHOW_TEXT);
1698
+ let node = walker.lastChild();
1699
+
1700
+ for (let position = runs.length - 1; position >= 0 && node; position -= 1) {
1701
+ const run = runs[position];
1702
+ const elapsed = now - run.startedAt;
1703
+ // This run has finished, and every run before it is older still.
1704
+ if (elapsed >= FADE_MS) break;
1705
+
1706
+ let remaining = run.length;
1707
+ while (remaining > 0 && node) {
1708
+ if (isInsideSettledSubtree(node, root)) {
1709
+ node = null;
1710
+ break;
1711
+ }
1712
+ if (!canFadeTextNode(node)) {
1713
+ // Still text the run paid for — the newlines between blocks are
1714
+ // counted in what was rendered — but nothing a fade would show.
1715
+ remaining -= node.length;
1716
+ node = walker.previousNode();
1717
+ continue;
1718
+ }
1719
+
1720
+ const take = Math.min(node.length, remaining);
1721
+ if (take === node.length) {
1722
+ const previous = walker.previousNode();
1723
+ wrapInFade(doc, node, elapsed);
1724
+ node = previous;
1725
+ } else {
1726
+ // The head of this node belongs to an older run, so leave the walk
1727
+ // standing on it rather than moving past.
1728
+ wrapInFade(doc, node.splitText(node.length - take), elapsed);
1729
+ }
1730
+ remaining -= take;
1731
+ }
1732
+ }
1733
+ }
1734
+
1735
+ /// Fade in whatever this frame added to one block.
1736
+ ///
1737
+ /// Runs are measured in rendered characters rather than in markdown, because
1738
+ /// markdown lies about length: `| 名称 | 数值 |` is thirteen characters of
1739
+ /// source and four of text, and fading thirteen would reach back into the row
1740
+ /// above and make settled text flash a second time.
1741
+ function paintBlockFade(block, added, now) {
1742
+ const runs = block.fadeRuns;
1743
+
1744
+ if (added > 0) {
1745
+ const newest = runs[runs.length - 1];
1746
+ // A line boundary always starts a new run, so that a list item or a table
1747
+ // row fades as the one thing it is instead of being blended across the
1748
+ // one before it.
1749
+ const startNew = !newest || block.fadeBreak || now - newest.startedAt >= FADE_STEP_MS;
1750
+ if (startNew) {
1751
+ runs.push({ length: added, startedAt: now });
1752
+ } else {
1753
+ newest.length += added;
1754
+ }
1755
+ block.fadeBreak = false;
1756
+ }
1757
+
1758
+ // Keep the fading region to the end of the text: a burst can reveal far
1759
+ // more in one frame than the gradient should ever cover.
1760
+ let budget = Math.min(MAX_FADE_CHARS, block.renderedLength);
1761
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
1762
+ if (budget <= 0) {
1763
+ runs.splice(0, index + 1);
1764
+ break;
1765
+ }
1766
+ if (runs[index].length > budget) runs[index].length = budget;
1767
+ budget -= runs[index].length;
1768
+ }
1769
+
1770
+ applyFadeRuns(block.container, runs, now);
1771
+ }
1772
+
1773
+ /// Retire the decoration that has run its course.
1774
+ ///
1775
+ /// Done before the paint rather than after it, because the paint asks which
1776
+ /// blocks are still animating in order to decide which ones may be frozen —
1777
+ /// ageing them afterwards would hold every freeze back by a frame, and the
1778
+ /// last one back forever, since a settled channel schedules no more frames.
1779
+ function retireBlockAnimations(entry, stream, now) {
1780
+ const animated = entry.message.status === 'streaming' && usesSmoothStreaming();
1781
+
1782
+ for (let index = 0; index < stream.blocks.length; index += 1) {
1783
+ const block = stream.blocks[index];
1784
+ if (block.settled) continue;
1785
+
1786
+ if (block.entering && (!animated || now - block.enteredAt >= BLOCK_ENTER_MS)) {
1787
+ block.entering = false;
1788
+ }
1789
+ if (!animated) {
1790
+ block.fadeRuns.length = 0;
1791
+ continue;
1792
+ }
1793
+ while (block.fadeRuns.length > 0 && now - block.fadeRuns[0].startedAt >= FADE_MS) {
1794
+ block.fadeRuns.shift();
1795
+ }
1796
+ }
1797
+ }
1798
+
1799
+ /// Decide whether this frame should repaint a block that has grown.
1800
+ ///
1801
+ /// Short blocks always repaint — that is the normal typewriter path and it is
1802
+ /// cheap. Once a block grows past the threshold, re-rendering it on every
1803
+ /// frame is quadratic in its length, so the configured mode takes over.
1804
+ function shouldRenderBlock(block, streaming) {
1805
+ if (!streaming) return true;
1806
+
1807
+ const streamingCfg = runtimeConfig.streaming || {};
1808
+ const threshold = typeof streamingCfg.longBlockThreshold === 'number'
1809
+ ? streamingCfg.longBlockThreshold
1810
+ : 512;
1811
+ if (block.revealed <= threshold) return true;
1812
+ if (block.complete && block.revealed >= block.source.length) return true;
1813
+ if (streamingCfg.longBlockMode === 'deferred') return false;
1814
+
1815
+ // Throttled: pace repaints against how long one actually takes, not against
1816
+ // how much text has arrived. Counting characters instead tied the wait to
1817
+ // the model's speed — 256 characters is a fifth of a second from a fast
1818
+ // model and the better part of ten seconds from a slow one, which is the
1819
+ // difference between a typewriter and a block that freezes mid-word and
1820
+ // then jumps a paragraph. Time keeps the motion continuous at either speed,
1821
+ // and scaling the interval with the measured cost keeps the total work
1822
+ // linear on the slowest device just as the character step did.
1823
+ const cost = block.lastRenderCost;
1824
+ const interval = Math.max(
1825
+ Math.min(cost * TAIL_REPAINT_DUTY, TAIL_REPAINT_MAX_GAP_MS),
1826
+ cost * TAIL_REPAINT_FLOOR_DUTY
1827
+ );
1828
+ return nowMs() - block.lastRenderAt >= interval;
1829
+ }
1830
+
1831
+ function ensureBlockContainer(channel, block) {
1832
+ if (block.container) return block.container;
1833
+ const container = documentRef.createElement('div');
1834
+ container.className = 'md-chat-block';
1835
+ block.container = container;
1836
+ // Blocks first render in document order — the typewriter runs forwards and
1837
+ // an atomic block is only released once the block before it has begun — so
1838
+ // appending is enough to keep the message in order.
1839
+ channel.content.appendChild(container);
1840
+ return container;
1841
+ }
1842
+
1843
+ /// What a block should be rendered with.
1844
+ ///
1845
+ /// A block that is finished is rendered as the document it will stay: real
1846
+ /// syntax highlighting, real KaTeX errors.
1847
+ ///
1848
+ /// A block still being typed defers the errors — red on a formula whose
1849
+ /// closing brace has not arrived is a lie that corrects itself a frame later
1850
+ /// — and defers *guessing* at code, which is not the same as defering the
1851
+ /// highlighting. A fence that names its language is highlighted from its
1852
+ /// first line: the language does not change as the body arrives, so nothing
1853
+ /// recolours. Only a fence that names nothing has to wait, because the only
1854
+ /// way to colour it is to re-guess the language on every frame. The library
1855
+ /// makes that call, since it is the side that knows which languages are
1856
+ /// actually registered — a name it does not recognise falls back to the same
1857
+ /// guess and so has to wait too.
1858
+ function blockOptions(block, streaming) {
1859
+ if (block.complete && block.revealed >= block.source.length) {
1860
+ return markdownOptions();
1861
+ }
1862
+ const inFence = Boolean(block.revealScan && block.revealScan.inFence);
1863
+ return markdownOptions(streaming && inFence, streaming);
1864
+ }
1865
+
1866
+ function paintBlock(entry, channel, block, streaming, now) {
1867
+ ensureBlockContainer(channel, block);
1868
+
1869
+ if (block.decorated) {
1870
+ clearFadeRuns(block.container);
1871
+ block.decorated = false;
1872
+ }
1873
+
1874
+ let added = 0;
1875
+ if (block.dirty) {
1876
+ const startedAt = nowMs();
1877
+ const before = block.renderedLength;
1878
+ commit(
1879
+ block.container,
1880
+ renderSegment(block.source.slice(0, block.revealed), blockOptions(block, streaming))
1881
+ );
1882
+ // Measured from the start of the render, so the interval a long block
1883
+ // waits covers its own cost rather than being added to it.
1884
+ block.lastRenderCost = nowMs() - startedAt;
1885
+ block.lastRenderAt = startedAt;
1886
+ block.renderedLength = block.container.textContent.length;
1887
+ added = Math.max(0, block.renderedLength - before);
1888
+ block.dirty = false;
1889
+ }
1890
+
1891
+ syncBlockEnter(block);
1892
+
1893
+ if (streaming && usesSmoothStreaming() && !block.atomic) {
1894
+ paintBlockFade(block, added, now);
1895
+ block.decorated = block.fadeRuns.length > 0;
1896
+ } else {
1897
+ block.fadeRuns.length = 0;
1898
+ }
1899
+ }
1900
+
1901
+ /// Put the enter animation on — or take it off — the block's own top-level
1902
+ /// elements. A wrapper would need a layout box of its own, and a block
1903
+ /// container that is `display: contents` deliberately has none.
1904
+ function syncBlockEnter(block) {
1905
+ if (block.entering === block.enterApplied) return;
1906
+ const container = block.container;
1907
+ if (!container) return;
1908
+
1909
+ let node = container.firstChild;
1910
+ while (node) {
1911
+ if (node.nodeType === 1) {
1912
+ if (block.entering) node.setAttribute(BLOCK_ENTER_ATTRIBUTE, '');
1913
+ else node.removeAttribute(BLOCK_ENTER_ATTRIBUTE);
1914
+ }
1915
+ node = node.nextSibling;
1916
+ }
1917
+ block.enterApplied = block.entering;
1918
+ }
1919
+
1920
+ /// True when nothing will ever change this block again.
1921
+ function isBlockFinished(block) {
1922
+ return block.complete
1923
+ && block.revealed >= block.source.length
1924
+ && !block.dirty
1925
+ && !block.entering
1926
+ && !block.enterApplied
1927
+ && !block.decorated
1928
+ && block.fadeRuns.length === 0;
1929
+ }
1930
+
1931
+ /// Freeze the finished blocks at the front of the message.
1932
+ ///
1933
+ /// Only a prefix, and only a contiguous one: a block is frozen once it and
1934
+ /// everything before it is done, which is what keeps an atomic block that
1935
+ /// landed early from being frozen while the paragraph above it is still
1936
+ /// being typed and could yet reflow around it.
1937
+ function freezeFinishedBlocks(stream) {
1938
+ for (let index = 0; index < stream.blocks.length; index += 1) {
1939
+ const block = stream.blocks[index];
1940
+ if (block.settled) continue;
1941
+ if (!isBlockFinished(block)) break;
1942
+ block.settled = true;
1943
+ // Nothing will ask about the block's internal state again.
1944
+ block.revealScan = null;
1945
+ }
1946
+ }
1947
+
1948
+ /// Mark the first and last blocks that actually rendered something, and
1949
+ /// report whether the channel has any content at all.
1950
+ ///
1951
+ /// The margin above the first paragraph and below the last one belongs to the
1952
+ /// bubble, not to the block, and which block carries those edges changes as
1953
+ /// the message grows. CSS has no way to say "the first child that is not
1954
+ /// empty", so the answer is written down instead of being asked for.
1955
+ function syncChannelEdges(channel) {
1956
+ let first = null;
1957
+ let last = null;
1958
+ const blocks = channel.stream.blocks;
1959
+
1960
+ for (let index = 0; index < blocks.length; index += 1) {
1961
+ const container = blocks[index].container;
1962
+ if (!container || !container.firstChild) continue;
1963
+ if (!first) first = container;
1964
+ last = container;
1965
+ }
1966
+
1967
+ if (channel.firstEdge !== first) {
1968
+ if (channel.firstEdge) channel.firstEdge.removeAttribute('data-md-first');
1969
+ if (first) first.setAttribute('data-md-first', '');
1970
+ channel.firstEdge = first;
1971
+ }
1972
+ if (channel.lastEdge !== last) {
1973
+ if (channel.lastEdge) channel.lastEdge.removeAttribute('data-md-last');
1974
+ if (last) last.setAttribute('data-md-last', '');
1975
+ channel.lastEdge = last;
1976
+ }
1977
+
1978
+ setAttr(channel.content, 'data-empty', first ? 'false' : 'true');
1979
+ }
1980
+
1981
+ function resetChannel(channel) {
1982
+ if (typeof channel.content.replaceChildren === 'function') {
1983
+ channel.content.replaceChildren();
1984
+ } else {
1985
+ channel.content.innerHTML = '';
1986
+ }
1987
+ channel.firstEdge = null;
1988
+ channel.lastEdge = null;
1989
+ }
1990
+
1991
+ /// Rebuild a channel from scratch, for the paths where the text was replaced
1992
+ /// rather than extended and nothing already on screen can be reused.
1993
+ function fullRenderChannel(entry, channel) {
1994
+ const stream = channel.stream;
1995
+ resetChannel(channel);
1996
+ stream.blocks.length = 0;
1997
+ stream.scanState = newScanState();
1998
+ if (entry.message.status !== 'streaming' && entry.message.status !== 'pending') {
1999
+ stream.ended = true;
2000
+ }
2001
+ ingest(stream);
2002
+ revealEverything(stream);
2003
+
2004
+ const streaming = entry.message.status === 'streaming';
2005
+ const now = nowMs();
2006
+ for (let index = 0; index < stream.blocks.length; index += 1) {
2007
+ const block = stream.blocks[index];
2008
+ if (!block.dirty) continue;
2009
+ paintBlock(entry, channel, block, streaming, now);
2010
+ }
2011
+
2012
+ syncChannelEdges(channel);
2013
+ freezeFinishedBlocks(stream);
2014
+ }
2015
+
2016
+ /// Complete the message if this flush drained the last of its text.
2017
+ ///
2018
+ /// A response is finished only once *both* channels have shown everything
2019
+ /// they were handed: reasoning still typing itself out is as much part of the
2020
+ /// answer as the answer is, and flipping the status early ends the stream
2021
+ /// while text is still on its way to the screen.
2022
+ function completeIfDrained(entry, channel) {
2023
+ if (!entry.finalRequested || hasPendingText(entry)) return false;
2024
+
2025
+ entry.finalRequested = false;
2026
+ entry.answer.stream.finalRequested = false;
2027
+ if (entry.reasoning) entry.reasoning.stream.finalRequested = false;
2028
+ entry.message.status = 'completed';
2029
+ markReasoningDone(entry);
2030
+ updateElementMetadata(entry);
2031
+
2032
+ // The other channel last painted while the message was still streaming, so
2033
+ // whatever decoration it is carrying has to be taken back off.
2034
+ if (channel !== entry.answer) {
2035
+ scheduleChannelFlush(entry, entry.answer, 'chat-final', false);
2036
+ } else if (entry.reasoning) {
2037
+ scheduleChannelFlush(entry, entry.reasoning, 'chat-final', false);
2038
+ }
2039
+ return true;
2040
+ }
2041
+
2042
+ function flushChannel(entry, channel) {
2043
+ const stream = channel.stream;
2044
+ stream.pendingFrame = null;
2045
+ if (stream.renderingInProgress) {
2046
+ stream.pendingFrame = raf(() => flushChannel(entry, channel));
2047
+ return;
2048
+ }
2049
+
2050
+ stream.renderingInProgress = true;
2051
+ try {
2052
+ const now = nowMs();
2053
+ ingest(stream);
2054
+
2055
+ // `fullRerender` asks for exactly what it says: nothing is ever frozen,
2056
+ // and every block is rebuilt on every frame. It exists as an escape
2057
+ // hatch for hosts that hit a diffing bug, and costs what it sounds like.
2058
+ const incremental = !runtimeConfig.streaming
2059
+ || runtimeConfig.streaming.incremental !== false;
2060
+ if (!incremental) {
2061
+ for (let index = 0; index < stream.blocks.length; index += 1) {
2062
+ stream.blocks[index].settled = false;
2063
+ stream.blocks[index].dirty = true;
2064
+ }
2065
+ }
2066
+
2067
+ const paced = usesSmoothStreaming()
2068
+ && !stream.instant
2069
+ && entry.message.status === 'streaming';
2070
+ // True when nothing this frame can do will change the screen, and only
2071
+ // more text will: the typewriter is holding a line back until it is
2072
+ // whole, or everything it could show is already shown and what is left
2073
+ // belongs to an atomic block that has not finished arriving.
2074
+ let waitingOnText = false;
2075
+ if (paced) {
2076
+ const budget = revealBudget(stream, now);
2077
+ const revealed = advanceLane(stream, budget);
2078
+ const released = releaseAtomicBlocks(stream, now);
2079
+ waitingOnText = !released
2080
+ && (laneBacklog(stream) === 0 || (budget > 0 && revealed === 0));
2081
+ } else {
2082
+ revealEverything(stream);
2083
+ }
2084
+
2085
+ const isFinalFlush = completeIfDrained(entry, channel);
2086
+ const streaming = entry.message.status === 'streaming';
2087
+ retireBlockAnimations(entry, stream, now);
2088
+
2089
+ let painted = false;
2090
+ for (let index = 0; index < stream.blocks.length; index += 1) {
2091
+ const block = stream.blocks[index];
2092
+ if (block.settled) continue;
2093
+ // `enterApplied` matters as much as `entering`: a block whose enter
2094
+ // animation has just finished still has the attribute on it, and one
2095
+ // more visit is what takes it back off.
2096
+ const decorating = block.entering
2097
+ || block.enterApplied
2098
+ || block.decorated
2099
+ || block.fadeRuns.length > 0;
2100
+ if (!block.dirty && !decorating) continue;
2101
+ if (block.dirty && !shouldRenderBlock(block, streaming)) continue;
2102
+ paintBlock(entry, channel, block, streaming, now);
2103
+ painted = true;
2104
+ }
2105
+
2106
+ if (painted) syncChannelEdges(channel);
2107
+ if (incremental) freezeFinishedBlocks(stream);
2108
+
2109
+ // Keep the loop alive while anything is still moving, not just while text
2110
+ // is still waiting: a block has to be visited by a later frame for its
2111
+ // decoration to come off and for it to be frozen. A model that stops
2112
+ // mid-answer would otherwise leave both hanging until it started again.
2113
+ const stillMoving = channelIsAnimating(stream)
2114
+ || (hasUnrevealed(stream) && !waitingOnText);
2115
+ if (usesSmoothStreaming() && stillMoving && stream.pendingFrame === null) {
2116
+ stream.pendingFrame = raf(() => flushChannel(entry, channel));
2117
+ }
2118
+
2119
+ // Nothing changed on screen when no block painted, so measuring the
2120
+ // height and re-pinning the scroll would only force needless layout.
2121
+ if (painted) {
2122
+ scheduleHeight(isFinalFlush ? 'chat-final' : stream.pendingReason, isFinalFlush);
2123
+ maybeAutoScroll();
2124
+ }
2125
+ } finally {
2126
+ stream.renderingInProgress = false;
2127
+ }
2128
+ }
2129
+
2130
+ function channelIsAnimating(stream) {
2131
+ for (let index = 0; index < stream.blocks.length; index += 1) {
2132
+ const block = stream.blocks[index];
2133
+ if (block.settled) continue;
2134
+ if (
2135
+ block.dirty
2136
+ || block.entering
2137
+ || block.enterApplied
2138
+ || block.decorated
2139
+ || block.fadeRuns.length > 0
2140
+ ) {
2141
+ return true;
2142
+ }
2143
+ }
2144
+ return false;
2145
+ }
2146
+
2147
+ function scheduleChannelFlush(entry, channel, reason, isFinal) {
2148
+ const stream = channel.stream;
2149
+ stream.pendingReason = reason || stream.pendingReason;
2150
+ if (isFinal) {
2151
+ entry.finalRequested = true;
2152
+ // No more text is coming, so the last block is closed and the playout
2153
+ // has nothing left to smooth against — both change how the next flush
2154
+ // paces itself.
2155
+ stream.ended = true;
2156
+ }
2157
+
2158
+ if (usesSmoothStreaming()) {
2159
+ if (isFinal) stream.finalRequested = true;
2160
+ if (stream.pendingFrame !== null) return;
2161
+ stream.pendingFrame = raf(() => flushChannel(entry, channel));
2162
+ return;
2163
+ }
2164
+ if (isFinal) {
2165
+ stream.finalRequested = true;
2166
+ cancelFrame(stream.pendingFrame);
2167
+ stream.pendingFrame = null;
2168
+ flushChannel(entry, channel);
2169
+ return;
2170
+ }
2171
+ if (stream.pendingFrame !== null) return;
2172
+ stream.pendingFrame = raf(() => flushChannel(entry, channel));
2173
+ }
2174
+
2175
+ // -------------------------------------------------------------------------
2176
+ // Feeding the channels
2177
+ // -------------------------------------------------------------------------
2178
+
2179
+ /// Split newly arrived answer text into its reasoning and answer halves.
2180
+ ///
2181
+ /// The splitter is stateful because a `<think>` marker routinely straddles
2182
+ /// two chunks; it lives on the entry so its "currently inside a think block"
2183
+ /// state survives across the whole response.
2184
+ function routeIncoming(entry, text, final) {
2185
+ if (entry.message.role !== 'assistant' || !inlineReasoningEnabled()) {
2186
+ return { reasoning: '', answer: text || '' };
2187
+ }
2188
+ if (!entry.splitter) {
2189
+ entry.splitter = renderLib.createReasoningSplitter({ tags: reasoningConfig().tags });
2190
+ }
2191
+
2192
+ const routed = entry.splitter.push(text || '');
2193
+ if (!final) return routed;
2194
+
2195
+ // Nothing more is coming, so a tail held back in case it grew into a
2196
+ // marker is just text.
2197
+ const tail = entry.splitter.flush();
2198
+ return {
2199
+ reasoning: routed.reasoning + tail.reasoning,
2200
+ answer: routed.answer + tail.answer
2201
+ };
2202
+ }
2203
+
2204
+ function pushChannelText(channel, text, smooth) {
2205
+ const stream = channel.stream;
2206
+ if (!stream.startedAt) {
2207
+ stream.startedAt = nowMs();
2208
+ // Text the channel was seeded with was never streamed, and counting it
2209
+ // against the time since would report a model typing at thousands of
2210
+ // characters a second.
2211
+ stream.originLength = stream.received.length;
2212
+ }
2213
+ stream.received += text;
2214
+ // A host that asked for the text to appear at once has said the reveal is
2215
+ // not wanted; the playout buffer would only delay it.
2216
+ if (!smooth) stream.instant = true;
2217
+ }
2218
+
2219
+ function feedReasoning(entry, text, smooth, isFinal) {
2220
+ if (!text && !isFinal) return;
2221
+ if (!reasoningConfig().enabled) return;
2222
+
2223
+ ensureReasoningView(entry);
2224
+ setReasoningState(entry, 'thinking');
2225
+ if (text) pushChannelText(entry.reasoning, text, smooth);
2226
+ scheduleChannelFlush(entry, entry.reasoning, 'chat-reasoning', Boolean(isFinal));
2227
+ }
2228
+
2229
+ function feedAnswer(entry, text, smooth, isFinal) {
2230
+ const routed = routeIncoming(entry, text, Boolean(isFinal));
2231
+ if (routed.reasoning) {
2232
+ feedReasoning(entry, routed.reasoning, smooth, false);
2233
+ }
2234
+ if (routed.answer) {
2235
+ pushChannelText(entry.answer, routed.answer, smooth);
2236
+ // The first real answer text is what ends the wait, so that is when the
2237
+ // fold collapses — not when the closing marker arrived, which is often a
2238
+ // beat earlier and leaves the reader looking at nothing.
2239
+ if (routed.answer.trim()) markReasoningDone(entry);
2240
+ }
2241
+ scheduleChannelFlush(entry, entry.answer, 'chat-append', Boolean(isFinal));
2242
+ }
2243
+
2244
+ /// Re-render a message from scratch: the text was replaced rather than
2245
+ /// extended, so nothing already on screen can be reused.
2246
+ function renderEntryNow(entry) {
2247
+ cancelFrame(entry.answer.stream.pendingFrame);
2248
+ if (entry.reasoning) cancelFrame(entry.reasoning.stream.pendingFrame);
2249
+ entry.finalRequested = false;
2250
+ entry.splitter = null;
2251
+
2252
+ const settled = entry.message.status !== 'streaming' && entry.message.status !== 'pending';
2253
+ const routed = routeIncoming(entry, entry.message.markdown, settled);
2254
+ const reasoningText = [entry.message.reasoning, routed.reasoning]
2255
+ .filter((part) => typeof part === 'string' && part.length > 0)
2256
+ .join('');
2257
+
2258
+ entry.answer.stream = newStreamState(routed.answer);
2259
+ fullRenderChannel(entry, entry.answer);
2260
+
2261
+ if (reasoningText && reasoningConfig().enabled) {
2262
+ const view = ensureReasoningView(entry);
2263
+ entry.reasoning.stream = newStreamState(reasoningText);
2264
+ fullRenderChannel(entry, entry.reasoning);
2265
+ // A finished message shows a fold the reader can open; one still being
2266
+ // produced keeps it open, because the reasoning is all there is to read.
2267
+ const stillThinking = !settled
2268
+ && (entry.splitter ? entry.splitter.isReasoning() : false);
2269
+ view.state = 'idle';
2270
+ setReasoningState(entry, stillThinking ? 'thinking' : 'done');
2271
+ if (settled && !view.userToggled) {
2272
+ setReasoningExpanded(entry, Boolean(reasoningConfig().defaultExpanded));
2273
+ }
2274
+ syncReasoningHeader(entry);
2275
+ } else {
2276
+ removeReasoningView(entry);
2277
+ }
2278
+ }
2279
+
2280
+ function scrollingElement() {
2281
+ return documentRef.scrollingElement || documentRef.documentElement || documentRef.body;
2282
+ }
2283
+
2284
+ function isNearBottom() {
2285
+ const scroller = scrollingElement();
2286
+ if (!scroller) return true;
2287
+ const threshold = Number(runtimeConfig.chat.bottomThreshold) || 96;
2288
+ return scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= threshold;
2289
+ }
2290
+
2291
+ function viewportState() {
2292
+ const scroller = scrollingElement();
2293
+ if (!scroller) {
2294
+ return {
2295
+ isNearBottom: true,
2296
+ scrollTop: 0,
2297
+ contentHeight: 0,
2298
+ viewportHeight: 0
2299
+ };
2300
+ }
2301
+ return {
2302
+ isNearBottom: isNearBottom(),
2303
+ scrollTop: Math.max(0, Number(scroller.scrollTop) || 0),
2304
+ contentHeight: Math.max(0, Number(scroller.scrollHeight) || 0),
2305
+ viewportHeight: Math.max(0, Number(scroller.clientHeight) || 0)
2306
+ };
2307
+ }
2308
+
2309
+ function notifyViewport() {
2310
+ state.viewportFrame = null;
2311
+ dispatchChatEvent('markdown-chat-viewport', viewportState());
2312
+ }
2313
+
2314
+ function scheduleViewport() {
2315
+ if (state.viewportFrame !== null) return;
2316
+ state.viewportFrame = raf(notifyViewport);
2317
+ }
2318
+
2319
+ function installViewportListeners() {
2320
+ if (state.viewportListenersInstalled || !documentRef) return;
2321
+ state.viewportListenersInstalled = true;
2322
+ documentRef.addEventListener('scroll', scheduleViewport, true);
2323
+ global.addEventListener('resize', scheduleViewport);
2324
+ scheduleViewport();
2325
+ }
2326
+
2327
+ function captureScrollIntent() {
2328
+ const mode = runtimeConfig.chat.autoScroll || 'nearBottom';
2329
+ state.shouldStickToBottom = mode === 'always' || (mode === 'nearBottom' && isNearBottom());
2330
+ }
2331
+
2332
+ function scrollToBottom(animated) {
2333
+ const scroller = scrollingElement();
2334
+ if (!scroller) return false;
2335
+ const top = scroller.scrollHeight;
2336
+ if (animated && typeof scroller.scrollTo === 'function') {
2337
+ scroller.scrollTo({ top, behavior: 'smooth' });
2338
+ } else if (typeof scroller.scrollTo === 'function') {
2339
+ scroller.scrollTo(0, top);
2340
+ } else {
2341
+ scroller.scrollTop = top;
2342
+ }
2343
+ scheduleViewport();
2344
+ return true;
2345
+ }
2346
+
2347
+ /// Room under the newest question, so that it can be scrolled to the top of
2348
+ /// the view and stay there while the answer is written.
2349
+ ///
2350
+ /// A conversation is only as tall as its content, so a question asked at the
2351
+ /// bottom of a short thread cannot rise more than a line or two. Without
2352
+ /// somewhere for it to go, keeping it in sight would mean moving the view
2353
+ /// down as the answer grew — which is the chasing this whole arrangement
2354
+ /// exists to stop. The room shrinks to nothing on its own once the answer is
2355
+ /// long enough to fill the screen by itself.
2356
+ function ensureTailRoom() {
2357
+ if (!state.container) return;
2358
+ const entry = state.anchorId ? state.messages.get(state.anchorId) : null;
2359
+ if (!entry) {
2360
+ releaseTailRoom();
2361
+ return;
2362
+ }
2363
+
2364
+ let spacer = state.tailRoom;
2365
+ if (!spacer) {
2366
+ spacer = documentRef.createElement('div');
2367
+ spacer.className = 'md-chat-tail-room';
2368
+ spacer.setAttribute('aria-hidden', 'true');
2369
+ state.tailRoom = spacer;
2370
+ }
2371
+ // Always last, and always in place before the measurement: the gap the
2372
+ // list puts between its children counts towards the height too, and a
2373
+ // spacer that is not there yet is a gap that is not there yet.
2374
+ if (spacer.parentNode !== state.container || spacer.nextSibling) {
2375
+ state.container.appendChild(spacer);
2376
+ }
2377
+ spacer.style.height = '0px';
2378
+
2379
+ const scroller = scrollingElement();
2380
+ if (!scroller) return;
2381
+ const anchorTop = entry.element.getBoundingClientRect().top + scroller.scrollTop;
2382
+ const needed = Math.round(anchorTop + scroller.clientHeight - scroller.scrollHeight);
2383
+ if (needed <= 0) {
2384
+ releaseTailRoom();
2385
+ return;
2386
+ }
2387
+ spacer.style.height = `${needed}px`;
2388
+ }
2389
+
2390
+ function releaseTailRoom() {
2391
+ if (state.tailRoom && state.tailRoom.parentNode) {
2392
+ state.tailRoom.parentNode.removeChild(state.tailRoom);
2393
+ }
2394
+ }
2395
+
2396
+ function clearScrollAnchor() {
2397
+ state.anchorId = null;
2398
+ releaseTailRoom();
2399
+ }
2400
+
2401
+ /// Put a message at the top of the view and leave it there.
2402
+ ///
2403
+ /// This is the other half of turning auto-scroll off. A reader who has just
2404
+ /// asked a question wants to see the question and the answer growing under
2405
+ /// it; what they do not want is the view moving on its own while they read.
2406
+ /// Moving once, on their own action, and then holding still is the whole of
2407
+ /// it — every scroll after this one is theirs to make.
2408
+ function scrollToMessage(id, options) {
2409
+ if (!ensureMounted()) return false;
2410
+ const messageId = String(id);
2411
+ const entry = state.messages.get(messageId);
2412
+ if (!entry) return false;
2413
+
2414
+ const opts = options || {};
2415
+ state.anchorId = messageId;
2416
+ ensureTailRoom();
2417
+
2418
+ const scroller = scrollingElement();
2419
+ if (!scroller) return false;
2420
+ const offset = Number(opts.offset) || 0;
2421
+ const anchorTop = entry.element.getBoundingClientRect().top + scroller.scrollTop;
2422
+ const top = Math.max(0, Math.round(anchorTop - offset));
2423
+
2424
+ // The reader was taken somewhere deliberately, so nothing that arrives
2425
+ // afterwards may take them somewhere else.
2426
+ state.shouldStickToBottom = false;
2427
+ if (opts.animated !== false && typeof scroller.scrollTo === 'function') {
2428
+ scroller.scrollTo({ top, behavior: 'smooth' });
2429
+ } else if (typeof scroller.scrollTo === 'function') {
2430
+ scroller.scrollTo(0, top);
2431
+ } else {
2432
+ scroller.scrollTop = top;
2433
+ }
2434
+ scheduleViewport();
2435
+ return true;
2436
+ }
2437
+
2438
+ function maybeAutoScroll() {
2439
+ const mode = runtimeConfig.chat.autoScroll || 'nearBottom';
2440
+ if (mode === 'never') return;
2441
+ if (runtimeConfig.chat.preserveUserScroll !== false && !state.shouldStickToBottom) return;
2442
+
2443
+ // Scroll in the same task as the mutation that grew the content, so the
2444
+ // browser lays both out for the same paint. Deferring to the next animation
2445
+ // frame published one frame with taller content but a stale scroll offset,
2446
+ // which read as the whole block dropping a line and snapping back on every
2447
+ // streamed line — roughly ten visible jumps per second.
2448
+ //
2449
+ // Every caller runs straight after a DOM write, so reading scrollHeight
2450
+ // here forces the layout that was due before paint anyway.
2451
+ if (state.scrollFrame !== null) {
2452
+ cancelFrame(state.scrollFrame);
2453
+ state.scrollFrame = null;
2454
+ }
2455
+ scrollToBottom(false);
2456
+ }
2457
+
2458
+ function measureHeight() {
2459
+ if (!documentRef) return 0;
2460
+ const bodyHeight = documentRef.body ? documentRef.body.scrollHeight : 0;
2461
+ const containerHeight = state.container ? state.container.scrollHeight : 0;
2462
+ return Math.max(bodyHeight, containerHeight);
2463
+ }
2464
+
2465
+ function scheduleHeight(reason, initial) {
2466
+ cancelFrame(state.heightFrame);
2467
+ state.heightFrame = raf(() => {
2468
+ state.heightFrame = null;
2469
+ // Before the measurement, so the height the host is told about is the
2470
+ // one the reader can actually scroll through.
2471
+ ensureTailRoom();
2472
+ const height = measureHeight();
2473
+ if (initial && bridge && typeof bridge.notifyRenderComplete === 'function') {
2474
+ bridge.notifyRenderComplete({ success: true, height, reason: reason || 'chat-render' });
2475
+ } else if (bridge && typeof bridge.notifyContentHeight === 'function') {
2476
+ bridge.notifyContentHeight(height, reason || 'chat-change');
2477
+ }
2478
+ scheduleViewport();
2479
+ });
2480
+ }
2481
+
2482
+ function forEachChannel(entry, visit) {
2483
+ visit(entry.answer);
2484
+ if (entry.reasoning) visit(entry.reasoning);
2485
+ }
2486
+
2487
+ function setMessages(messages) {
2488
+ if (!ensureMounted()) return false;
2489
+ captureScrollIntent();
2490
+ clearScrollAnchor();
2491
+ cancelFrame(state.heightFrame);
2492
+ state.messages.forEach((entry) => {
2493
+ forEachChannel(entry, (channel) => cancelFrame(channel.stream.pendingFrame));
2494
+ });
2495
+ state.messages.clear();
2496
+ state.order = [];
2497
+ state.container.innerHTML = '';
2498
+
2499
+ (Array.isArray(messages) ? messages : []).forEach((rawMessage) => {
2500
+ const message = normalizeMessage(rawMessage);
2501
+ const entry = createEntry(message);
2502
+ state.messages.set(message.id, entry);
2503
+ state.order.push(message.id);
2504
+ state.container.appendChild(entry.element);
2505
+ renderEntryNow(entry);
2506
+ });
2507
+
2508
+ scheduleHeight('chat-set-messages', true);
2509
+ maybeAutoScroll();
2510
+ return true;
2511
+ }
2512
+
2513
+ function appendMessage(rawMessage) {
2514
+ if (!ensureMounted()) return false;
2515
+ captureScrollIntent();
2516
+ const message = normalizeMessage(rawMessage);
2517
+ const existing = state.messages.get(message.id);
2518
+ if (existing) {
2519
+ return updateMessage(message.id, message);
2520
+ }
2521
+
2522
+ const entry = createEntry(message);
2523
+ state.messages.set(message.id, entry);
2524
+ state.order.push(message.id);
2525
+ state.container.appendChild(entry.element);
2526
+ renderEntryNow(entry);
2527
+ scheduleHeight('chat-append-message', true);
2528
+ maybeAutoScroll();
2529
+ return true;
2530
+ }
2531
+
2532
+ /// True when `next` merely continues `previous`, which is the shape every
2533
+ /// streamed update has: the host resends the whole message with more text on
2534
+ /// the end. Anything else is a rewrite and has to be re-rendered.
2535
+ function grewFrom(previous, next) {
2536
+ return next.length > previous.length && next.startsWith(previous);
2537
+ }
2538
+
2539
+ function updateMessage(id, patch) {
2540
+ if (!ensureMounted()) return false;
2541
+ captureScrollIntent();
2542
+ const messageId = String(id);
2543
+ const entry = state.messages.get(messageId);
2544
+ if (!entry) {
2545
+ const next = Object.assign({}, patch || {}, { id: messageId });
2546
+ return appendMessage(next);
2547
+ }
2548
+
2549
+ const previous = entry.message;
2550
+ const normalized = normalizeMessage(Object.assign({}, previous, patch || {}, { id: messageId }));
2551
+ entry.message = normalized;
2552
+ updateElementMetadata(entry);
2553
+
2554
+ const markdownChanged = normalized.markdown !== previous.markdown;
2555
+ const reasoningChanged = normalized.reasoning !== previous.reasoning;
2556
+ const markdownGrew = markdownChanged && Boolean(previous.markdown)
2557
+ && grewFrom(previous.markdown, normalized.markdown);
2558
+ const reasoningGrew = reasoningChanged
2559
+ && grewFrom(previous.reasoning || '', normalized.reasoning);
2560
+
2561
+ if ((markdownChanged && !markdownGrew) || (reasoningChanged && !reasoningGrew)) {
2562
+ renderEntryNow(entry);
2563
+ scheduleHeight('chat-update-message', true);
2564
+ maybeAutoScroll();
2565
+ return true;
2566
+ }
2567
+
2568
+ if (!markdownChanged && !reasoningChanged) {
2569
+ // A status-only change (pending → streaming, streaming → completed)
2570
+ // leaves every character exactly where it was. Re-rendering it tore the
2571
+ // message off the screen and rebuilt it, and because that update lands
2572
+ // the moment an answer finishes, it was the most visible flash in the
2573
+ // whole runtime.
2574
+ if (normalized.status !== 'streaming' && hasPendingText(entry)) {
2575
+ entry.finalRequested = true;
2576
+ forEachChannel(entry, (channel) => {
2577
+ scheduleChannelFlush(entry, channel, 'chat-update-message', true);
2578
+ });
2579
+ } else if (normalized.status !== 'streaming') {
2580
+ markReasoningDone(entry);
2581
+ }
2582
+ scheduleHeight('chat-update-message', true);
2583
+ maybeAutoScroll();
2584
+ return true;
2585
+ }
2586
+
2587
+ // The message only grew. Feed the delta through the streaming path so the
2588
+ // new text is appended to what is already on screen.
2589
+ const isFinal = normalized.status !== 'streaming';
2590
+ const smooth = usesSmoothStreaming() && !isFinal;
2591
+
2592
+ if (reasoningGrew) {
2593
+ feedReasoning(
2594
+ entry,
2595
+ normalized.reasoning.slice((previous.reasoning || '').length),
2596
+ smooth,
2597
+ isFinal && !markdownGrew
2598
+ );
2599
+ }
2600
+ if (markdownGrew) {
2601
+ feedAnswer(
2602
+ entry,
2603
+ normalized.markdown.slice(previous.markdown.length),
2604
+ smooth,
2605
+ isFinal
2606
+ );
2607
+ } else if (isFinal) {
2608
+ entry.finalRequested = true;
2609
+ scheduleChannelFlush(entry, entry.answer, 'chat-update-message', true);
2610
+ }
2611
+ return true;
2612
+ }
2613
+
2614
+ function ensureStreamingEntry(id, role) {
2615
+ let entry = state.messages.get(id);
2616
+ if (entry) return entry;
2617
+ appendMessage({ id, role: role || 'assistant', markdown: '', status: 'streaming' });
2618
+ return state.messages.get(id) || null;
2619
+ }
2620
+
2621
+ function appendChunk(id, chunk, opts) {
2622
+ if (!ensureMounted()) return false;
2623
+ captureScrollIntent();
2624
+ const options = opts || {};
2625
+ const messageId = String(id);
2626
+ const entry = ensureStreamingEntry(messageId, options.role);
2627
+ if (!entry) return false;
2628
+
2629
+ const text = typeof chunk === 'string' ? chunk : '';
2630
+ entry.message.markdown += text;
2631
+ entry.message.status = usesSmoothStreaming()
2632
+ ? 'streaming'
2633
+ : (options.isLast ? 'completed' : 'streaming');
2634
+ updateElementMetadata(entry);
2635
+ feedAnswer(entry, text, usesSmoothStreaming(), Boolean(options.isLast));
2636
+ return true;
2637
+ }
2638
+
2639
+ /// Append to the reasoning channel directly.
2640
+ ///
2641
+ /// This is the path for providers that stream the chain of thought in its own
2642
+ /// SSE field (`reasoning_content`, `reasoning`) rather than inline in the
2643
+ /// content — there are no markers to find, the host already knows which field
2644
+ /// it read.
2645
+ function appendReasoningChunk(id, chunk, opts) {
2646
+ if (!ensureMounted()) return false;
2647
+ if (!reasoningConfig().enabled) return false;
2648
+ captureScrollIntent();
2649
+ const options = opts || {};
2650
+ const messageId = String(id);
2651
+ const entry = ensureStreamingEntry(messageId, options.role);
2652
+ if (!entry) return false;
2653
+
2654
+ const text = typeof chunk === 'string' ? chunk : '';
2655
+ entry.message.reasoning += text;
2656
+ if (entry.message.status === 'pending') {
2657
+ entry.message.status = 'streaming';
2658
+ updateElementMetadata(entry);
2659
+ }
2660
+ feedReasoning(entry, text, usesSmoothStreaming(), Boolean(options.isLast));
2661
+ return true;
2662
+ }
2663
+
2664
+ /// Close the fold without ending the message: the model has stopped thinking
2665
+ /// and the answer is about to start.
2666
+ function finishReasoning(id) {
2667
+ if (!ensureMounted()) return false;
2668
+ const entry = state.messages.get(String(id));
2669
+ if (!entry || !entry.reasoningView) return false;
2670
+ markReasoningDone(entry);
2671
+ scheduleHeight('chat-reasoning-final', false);
2672
+ return true;
2673
+ }
2674
+
2675
+ /// Open or close a message's fold from the host, as if the reader had tapped
2676
+ /// it — the choice sticks for the rest of the message.
2677
+ function setReasoningVisible(id, expanded) {
2678
+ if (!ensureMounted()) return false;
2679
+ const entry = state.messages.get(String(id));
2680
+ if (!entry || !entry.reasoningView) return false;
2681
+ entry.reasoningView.userToggled = true;
2682
+ setReasoningExpanded(entry, Boolean(expanded));
2683
+ scheduleHeight('chat-reasoning-toggle', false);
2684
+ return true;
2685
+ }
2686
+
2687
+ function finishMessage(id) {
2688
+ if (!ensureMounted()) return false;
2689
+ const entry = state.messages.get(String(id));
2690
+ if (!entry) return false;
2691
+
2692
+ entry.finalRequested = true;
2693
+ entry.message.status = usesSmoothStreaming() && hasPendingText(entry)
2694
+ ? 'streaming'
2695
+ : 'completed';
2696
+ updateElementMetadata(entry);
2697
+ if (entry.reasoning) {
2698
+ scheduleChannelFlush(entry, entry.reasoning, 'chat-final', true);
2699
+ }
2700
+ scheduleChannelFlush(entry, entry.answer, 'chat-final', true);
2701
+ return true;
2702
+ }
2703
+
2704
+ function removeMessage(id) {
2705
+ if (!ensureMounted()) return false;
2706
+ captureScrollIntent();
2707
+ const messageId = String(id);
2708
+ const entry = state.messages.get(messageId);
2709
+ if (!entry) return false;
2710
+ if (state.anchorId === messageId) clearScrollAnchor();
2711
+ forEachChannel(entry, (channel) => cancelFrame(channel.stream.pendingFrame));
2712
+ entry.element.remove();
2713
+ state.messages.delete(messageId);
2714
+ state.order = state.order.filter((item) => item !== messageId);
2715
+ scheduleHeight('chat-remove-message', true);
2716
+ maybeAutoScroll();
2717
+ return true;
2718
+ }
2719
+
2720
+ function clearMessages() {
2721
+ if (!ensureMounted()) return false;
2722
+ cancelFrame(state.scrollFrame);
2723
+ state.scrollFrame = null;
2724
+ clearScrollAnchor();
2725
+ state.messages.forEach((entry) => {
2726
+ forEachChannel(entry, (channel) => cancelFrame(channel.stream.pendingFrame));
2727
+ });
2728
+ state.messages.clear();
2729
+ state.order = [];
2730
+ state.container.innerHTML = '';
2731
+ scheduleHeight('chat-clear', true);
2732
+ return true;
2733
+ }
2734
+
2735
+ global.MarkdownChatRenderer = {
2736
+ mount: ensureMounted,
2737
+ setMessages,
2738
+ appendMessage,
2739
+ updateMessage,
2740
+ appendChunk,
2741
+ appendReasoningChunk,
2742
+ finishReasoning,
2743
+ setReasoningVisible,
2744
+ finishMessage,
2745
+ removeMessage,
2746
+ clearMessages,
2747
+ scrollToBottom,
2748
+ scrollToMessage,
2749
+ getContentHeight: measureHeight,
2750
+ getViewportState: viewportState,
2751
+ debugState() {
2752
+ const reasoning = {};
2753
+ state.messages.forEach((entry, id) => {
2754
+ if (!entry.reasoningView) return;
2755
+ reasoning[id] = {
2756
+ state: entry.reasoningView.state,
2757
+ expanded: entry.reasoningView.expanded,
2758
+ label: entry.reasoningView.labelText,
2759
+ text: entry.reasoning.stream.received
2760
+ };
2761
+ });
2762
+ return {
2763
+ mounted: state.mounted,
2764
+ count: state.order.length,
2765
+ order: state.order.slice(),
2766
+ reasoning
2767
+ };
2768
+ }
2769
+ };
2770
+ })(window);