@cellgit/markdown-render 0.1.0 → 1.0.0
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.
- package/README.md +436 -178
- package/README.zh-CN.md +530 -0
- package/THIRD-PARTY-NOTICES.md +332 -0
- package/dist/markdown-render.css +3 -3
- package/dist/markdown-render.esm.css +3 -3
- package/dist/markdown-render.esm.js +1 -91452
- package/dist/markdown-render.html +20 -75
- package/dist/markdown-render.js +1 -91457
- package/dist/scripts/bridge.js +119 -0
- package/dist/scripts/chat-renderer.js +2770 -0
- package/dist/scripts/copy.js +155 -0
- package/dist/scripts/height-sync.js +205 -0
- package/dist/scripts/renderer.js +398 -0
- package/ios-example/MarkdownViewController.swift +123 -33
- package/ios-example/README.md +2 -0
- package/ios-example/SwiftUIMarkdownExample.swift +111 -0
- package/package.json +17 -9
- package/dist/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Bold.woff +0 -0
- package/dist/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/dist/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/dist/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Italic.woff +0 -0
- package/dist/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Main-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/dist/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/dist/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_Math-Italic.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/dist/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Script-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/dist/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/dist/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- 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);
|