fix: stream the assistant's answer a block at a time
Markdown only means anything once a block is finished. Half a table is a row of pipes; half a fence is a stray ```. Re-parsing the whole partial answer every 180ms therefore flickered between a broken parse and the real thing, and the previous answer to that was to abandon markdown past 3500 characters or eight pipe rows and dump raw text into a <pre> that had no CSS at all — inheriting the browser's black monospace default. That is the dark block people saw while a table streamed. Now the text is split at the last finished block — a blank line outside a code fence — and everything before it is rendered once and *appended*. Only the unfinished tail is plain text, styled as prose. Settled content is never re-parsed and never rebuilt, so a diagram or chart that has already drawn is not thrown away by the next token. Two blank lines are not boundaries: the gap inside a loose list, which would render one list as two each restarting at 1, and one inside indented code. Telling the first from the perfectly good boundary between a sentence and the list it introduces takes looking at both sides of the gap, not just ahead. renderEmbeddedBlocks now marks what it has drawn. mermaid.render is async and replaces the element; a second Chart on one canvas throws. Marked before the await, not after, or two frames race the same element. Streaming is a preview: 'done' still renders the whole answer from scratch, so anything transient here is settled by the final pass. That is what makes appending safe. Mutation-tested: removing fence tracking, the list guard, the backward half of that guard, the append, the drawn marker, or the state reset in fillMessageBubble each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
a7132c218e
commit
89e71b3b86
4 changed files with 347 additions and 21 deletions
|
|
@ -177,6 +177,12 @@
|
|||
.assistant-source-excerpt ul, .assistant-source-excerpt ol { padding-left:16px; margin:4px 0; }
|
||||
.assistant-source-excerpt strong { color:var(--g700); }
|
||||
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
|
||||
/* The block still arriving. It keeps the bubble's own type — it was a bare
|
||||
<pre>, which fell back to the browser's black monospace default and was the
|
||||
dark flash people saw while a table streamed. pre-wrap so the line breaks of
|
||||
a half-finished table are visible rather than collapsed into one run. */
|
||||
.assistant-streaming-text { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; color:var(--g700); }
|
||||
.assistant-stream-settled:not(:empty) + .assistant-streaming-text:not([hidden]) { margin-top:10px; }
|
||||
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
|
||||
.assistant-source-modal { position:fixed; inset:0; z-index:10000; background:rgba(15,23,42,.72); display:flex; align-items:center; justify-content:center; padding:24px; }
|
||||
.assistant-source-modal .modal-content { width:100%; max-width:640px; max-height:88vh; overflow:auto; }
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import {
|
|||
var statusChoices = { allowedChatModels: [], allowedImageModels: [], chatModel: '', imageModel: '' };
|
||||
var activeAssistantRequest = null;
|
||||
var conversationChars = null;
|
||||
var STREAM_MARKDOWN_LIMIT = 3500;
|
||||
var AUTOSAVE_DELAY_MS = 800;
|
||||
var currentChatId = null;
|
||||
var autosaveTimer = null;
|
||||
|
|
@ -535,8 +534,7 @@ import {
|
|||
loading.classList.remove('assistant-loading-msg');
|
||||
bubble.classList.remove('assistant-thinking');
|
||||
bubble.assistantSources = streamSources;
|
||||
bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>';
|
||||
renderEmbeddedBlocks(bubble);
|
||||
renderStreamingInto(bubble, partial, streamSources);
|
||||
var wrap = document.getElementById('assistant-messages');
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
|
@ -600,18 +598,113 @@ import {
|
|||
setBusy(false, 'Ready');
|
||||
}
|
||||
|
||||
function renderStreamingAnswerHtml(text, sources) {
|
||||
if (shouldUseLightweightStreamingRender(text)) {
|
||||
return '<pre class="assistant-streaming-text">' + escapeHtml(text) + '</pre>';
|
||||
}
|
||||
return renderAssistantBubbleHtml(text, sources, false);
|
||||
// ---- streaming render -----------------------------------------------------
|
||||
//
|
||||
// Markdown only makes sense once a block is finished. Half a table is a row of
|
||||
// pipes; half a fence is a stray ```. Re-parsing the whole partial answer every
|
||||
// frame therefore flashed between a broken parse and the real thing, and the
|
||||
// old answer to that was to give up on markdown entirely past a size or a
|
||||
// pipe-row count and show raw text in a <pre> — the black monospace block.
|
||||
//
|
||||
// Instead: split the text at the last finished block, render everything before
|
||||
// it as markdown once and *append* it, and keep only the unfinished tail as
|
||||
// plain text. Settled content is never re-parsed and never re-rendered, so a
|
||||
// diagram or chart that has already drawn is not thrown away on the next
|
||||
// token, and the tail is the only thing that changes each frame.
|
||||
//
|
||||
// Streaming is a preview. The 'done' handler still renders the whole answer
|
||||
// from scratch through fillMessageBubble, so anything this shows mid-stream —
|
||||
// a loose list briefly split in two, a reference link not yet defined — is
|
||||
// settled correctly by the final pass. That is what makes appending safe.
|
||||
|
||||
function beginStreamingRender(bubble) {
|
||||
bubble.innerHTML = '';
|
||||
var state = {
|
||||
settled: document.createElement('div'),
|
||||
tail: document.createElement('p'),
|
||||
consumed: 0
|
||||
};
|
||||
state.settled.className = 'assistant-stream-settled';
|
||||
state.tail.className = 'assistant-streaming-text';
|
||||
bubble.appendChild(state.settled);
|
||||
bubble.appendChild(state.tail);
|
||||
bubble.assistantStreamState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function shouldUseLightweightStreamingRender(text) {
|
||||
text = String(text || '');
|
||||
if (text.length > STREAM_MARKDOWN_LIMIT) return true;
|
||||
var pipeRows = text.split('\n').filter(function (line) { return /^\s*\|.*\|\s*$/.test(line); }).length;
|
||||
return pipeRows >= 8;
|
||||
function renderStreamingInto(bubble, text, sources) {
|
||||
if (!text) {
|
||||
bubble.assistantStreamState = null;
|
||||
bubble.innerHTML = '<p class="assistant-muted">Generating answer...</p>';
|
||||
return;
|
||||
}
|
||||
var state = bubble.assistantStreamState || beginStreamingRender(bubble);
|
||||
var cut = settledMarkdownLength(text, state.consumed);
|
||||
if (cut > state.consumed) {
|
||||
var holder = document.createElement('div');
|
||||
holder.innerHTML = renderAssistantBubbleHtml(text.slice(state.consumed, cut), sources, false);
|
||||
state.consumed = cut;
|
||||
while (holder.firstChild) state.settled.appendChild(holder.firstChild);
|
||||
// Over the whole settled subtree, not just the new nodes: a newly appended
|
||||
// node may itself be the diagram, which querySelectorAll would skip. Blocks
|
||||
// already drawn are marked and skipped inside renderEmbeddedBlocks.
|
||||
renderEmbeddedBlocks(state.settled);
|
||||
}
|
||||
var tail = text.slice(state.consumed);
|
||||
state.tail.textContent = tail;
|
||||
state.tail.hidden = !tail;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of `text` is finished markdown, as a length.
|
||||
*
|
||||
* A blank line outside a code fence ends a block. Two exceptions, both of
|
||||
* them blank lines a block owns rather than is separated by: the gap between
|
||||
* the items of a loose list, where cutting renders one list as two each
|
||||
* restarting at 1, and a blank line within an indented code block. When
|
||||
* nothing qualifies — a single long paragraph, a fence opened on the first
|
||||
* line — this returns `from` and everything stays in the tail, which is both
|
||||
* the old behaviour and correct.
|
||||
*/
|
||||
function settledMarkdownLength(text, from) {
|
||||
var fence = null;
|
||||
var offset = 0;
|
||||
var best = from;
|
||||
var lines = String(text).split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
var opener = line.match(/^\s{0,3}(```+|~~~+)/);
|
||||
if (fence) {
|
||||
if (opener && opener[1][0] === fence[0] && opener[1].length >= fence.length) fence = null;
|
||||
} else if (opener) {
|
||||
fence = opener[1];
|
||||
} else if (!line.trim()) {
|
||||
// Past the blank line, so the settled chunk ends with the break and the
|
||||
// tail starts on real content.
|
||||
var cut = offset + line.length + 1;
|
||||
if (cut > from && !continuesBlock(lines, i)) best = cut;
|
||||
}
|
||||
offset += line.length + 1; // the \n that split() removed
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the blank line at `i` sits inside one block rather than between two.
|
||||
*
|
||||
* It takes both sides to tell: a list item *after* the blank only means a list
|
||||
* is being split if there was already one before it. Looking forward alone
|
||||
* rejects the perfectly good boundary between a sentence and the list it
|
||||
* introduces, which is most of what this assistant writes.
|
||||
*/
|
||||
function continuesBlock(lines, i) {
|
||||
var ITEM = /^(\s{4,}|\s*([-*+]|\d+[.)])\s)/;
|
||||
var next = '';
|
||||
for (var f = i + 1; f < lines.length; f++) { if (lines[f].trim()) { next = lines[f]; break; } }
|
||||
if (!next || !ITEM.test(next)) return false;
|
||||
var prev = '';
|
||||
for (var b = i - 1; b >= 0; b--) { if (lines[b].trim()) { prev = lines[b]; break; } }
|
||||
return ITEM.test(prev);
|
||||
}
|
||||
|
||||
function parseSseEvent(block) {
|
||||
|
|
@ -698,6 +791,9 @@ import {
|
|||
}
|
||||
|
||||
function fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options) {
|
||||
// The final render replaces everything, so the streaming state's nodes are
|
||||
// now detached; leaving it set would have a later frame append into nothing.
|
||||
bubble.assistantStreamState = null;
|
||||
bubble.assistantSources = role === 'assistant' && Array.isArray(sources) ? sources : [];
|
||||
bubble.assistantRawContent = String(content || '');
|
||||
var renderOptions = Object.assign({}, options || {}, { notice: '' });
|
||||
|
|
@ -919,13 +1015,24 @@ import {
|
|||
});
|
||||
}
|
||||
|
||||
// Mermaid diagrams and Chart.js canvases, drawn once each.
|
||||
//
|
||||
// Both are expensive and both are claimed: mermaid.render is async and
|
||||
// replaces the element's contents, and a second Chart on the same canvas
|
||||
// throws "Canvas is already in use". Streaming calls this again every time a
|
||||
// block settles, so each element is marked when it is taken and skipped
|
||||
// afterwards — otherwise a diagram near the top of a long answer would be torn
|
||||
// down and redrawn on every block that followed it.
|
||||
function renderEmbeddedBlocks(root) {
|
||||
wireCodeBlocks(root);
|
||||
function mermaidSource(el) {
|
||||
var raw = el.getAttribute('data-mermaid') || '';
|
||||
try { return decodeURIComponent(raw); } catch (e) { return raw; }
|
||||
}
|
||||
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
|
||||
root.querySelectorAll('[data-mermaid]:not([data-drawn])').forEach(function (el) {
|
||||
// Marked before the await, not after: two calls a frame apart would both
|
||||
// get past an await-side check and race to render the same element.
|
||||
el.setAttribute('data-drawn', '1');
|
||||
ensureMermaid().then(function () {
|
||||
if (!window.mermaid) { el.textContent = mermaidSource(el); return; }
|
||||
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
|
||||
|
|
@ -934,8 +1041,9 @@ import {
|
|||
.catch(function () { el.textContent = mermaidSource(el); });
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {
|
||||
if (!window.Chart) return;
|
||||
root.querySelectorAll('canvas[data-chart]:not([data-drawn])').forEach(function (canvas) {
|
||||
if (!window.Chart) return; // unmarked, so it draws if Chart.js arrives later
|
||||
canvas.setAttribute('data-drawn', '1');
|
||||
try {
|
||||
var cfg = JSON.parse(canvas.getAttribute('data-chart') || '{}');
|
||||
new window.Chart(canvas.getContext('2d'), cfg);
|
||||
|
|
|
|||
|
|
@ -273,12 +273,17 @@ test('does not repair malformed tab-separated clinical summary rows into a table
|
|||
assert.match(html, /If you need details/);
|
||||
});
|
||||
|
||||
test('clinical assistant streams long table answers as lightweight text before final render', async () => {
|
||||
test('a streaming table renders block by block, not as a raw text dump', async () => {
|
||||
// Previously a long or pipe-heavy answer gave up on markdown entirely and
|
||||
// went into an unstyled <pre> — the dark flash while a table streamed. Now
|
||||
// the finished blocks are markdown and only the unfinished tail is text.
|
||||
// Behaviour lives in test/assistant-streaming-blocks.test.js; this guards
|
||||
// against the old bail-out returning.
|
||||
const source = await fs.readFile(path.join(__dirname, '..', 'public', 'js', 'clinicalAssistant.js'), 'utf8');
|
||||
assert.match(source, /STREAM_MARKDOWN_LIMIT/);
|
||||
assert.match(source, /renderStreamingAnswerHtml\(partial, streamSources\)/);
|
||||
assert.match(source, /assistant-streaming-text/);
|
||||
assert.match(source, /pipeRows >= 8/);
|
||||
assert.doesNotMatch(source, /STREAM_MARKDOWN_LIMIT/);
|
||||
assert.doesNotMatch(source, /pipeRows >= 8/);
|
||||
assert.match(source, /function settledMarkdownLength\(text, from\)/);
|
||||
assert.match(source, /renderStreamingInto\(bubble, partial, streamSources\)/);
|
||||
});
|
||||
|
||||
test('mermaid source survives the sanitiser and round-trips', async () => {
|
||||
|
|
|
|||
207
test/assistant-streaming-blocks.test.js
Normal file
207
test/assistant-streaming-blocks.test.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// A markdown table only parses once its last row has arrived. Re-parsing the
|
||||
// whole partial answer every 180ms therefore flashed between a broken parse and
|
||||
// the real thing, and the old answer was to abandon markdown past a size or a
|
||||
// pipe-row count and dump raw text into an unstyled <pre> — the black monospace
|
||||
// block people saw while a table streamed.
|
||||
//
|
||||
// Now the text is split at the last finished block: everything before it is
|
||||
// rendered once and appended, and only the unfinished tail is plain text.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const { marked } = require('marked');
|
||||
|
||||
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
|
||||
function ui(t) {
|
||||
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>',
|
||||
{ url: 'https://example.test', runScripts: 'outside-only' });
|
||||
const window = dom.window;
|
||||
window.eval(read('public/js/accountBoundary.js'));
|
||||
window.AccountBoundary.enter({ id: 'synthetic-streaming-owner' }, true);
|
||||
window.marked = marked;
|
||||
window.DOMPurify = require('dompurify')(window);
|
||||
window.matchMedia = () => ({ matches: true });
|
||||
const context = {
|
||||
window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
||||
setTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
|
||||
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
||||
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
||||
saveAssistantChat: async () => ({ success: true })
|
||||
};
|
||||
vm.createContext(context);
|
||||
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js',
|
||||
'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
|
||||
vm.runInContext(read('public/js/' + file)
|
||||
.replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
|
||||
}
|
||||
t.after(() => window.close());
|
||||
return context;
|
||||
}
|
||||
|
||||
const bubbleFor = ctx => ctx.document.createElement('div');
|
||||
const TABLE = '| Drug | Dose |\n| --- | --- |\n| Adrenaline | 10 mcg/kg |\n| Amiodarone | 5 mg/kg |';
|
||||
|
||||
// ---- where the cut falls ---------------------------------------------------
|
||||
|
||||
test('a finished paragraph settles, the one still arriving does not', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
const text = 'First paragraph, complete.\n\nSecond one, still typ';
|
||||
const cut = settledMarkdownLength(text, 0);
|
||||
assert.equal(text.slice(0, cut).trim(), 'First paragraph, complete.');
|
||||
assert.equal(text.slice(cut).trim(), 'Second one, still typ');
|
||||
});
|
||||
|
||||
test('a half-written table stays in the tail rather than parsing as broken', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
const text = 'Resuscitation doses:\n\n| Drug | Dose |\n| --- | --- |\n| Adrenaline | 10 mc';
|
||||
const cut = settledMarkdownLength(text, 0);
|
||||
assert.equal(text.slice(0, cut).trim(), 'Resuscitation doses:');
|
||||
assert.ok(text.slice(cut).includes('| Adrenaline'));
|
||||
});
|
||||
|
||||
test('a blank line inside an open code fence is not a boundary', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
// Cutting here would render a stray ``` and leave the rest unfenced.
|
||||
const text = 'Example:\n\n```js\nconst a = 1;\n\nconst b = 2;';
|
||||
const cut = settledMarkdownLength(text, 0);
|
||||
assert.equal(text.slice(0, cut).trim(), 'Example:');
|
||||
});
|
||||
|
||||
test('a closed fence settles, and the fence state resets after it', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
const text = '```js\nconst a = 1;\n```\n\nAfter the block.\n\nStill writ';
|
||||
const cut = settledMarkdownLength(text, 0);
|
||||
assert.ok(text.slice(0, cut).includes('After the block.'));
|
||||
assert.equal(text.slice(cut).trim(), 'Still writ');
|
||||
});
|
||||
|
||||
test('the blank line inside a loose list is not a boundary', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
// Cutting there renders one list as two, each restarting at 1.
|
||||
const text = 'Steps:\n\n1. Airway\n\n2. Breathing\n\n3. Circu';
|
||||
const cut = settledMarkdownLength(text, 0);
|
||||
assert.equal(text.slice(0, cut).trim(), 'Steps:');
|
||||
assert.ok(text.slice(cut).startsWith('1. Airway'));
|
||||
});
|
||||
|
||||
test('but the blank line before a list starts is a boundary', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
// Rejecting on the following line alone would refuse this too, and a sentence
|
||||
// introducing a list is most of what this assistant writes.
|
||||
const text = 'Assess in this order:\n\n- Airway\n- Breath';
|
||||
const cut = settledMarkdownLength(text, 0);
|
||||
assert.equal(text.slice(0, cut).trim(), 'Assess in this order:');
|
||||
});
|
||||
|
||||
test('an unbroken paragraph settles nothing, which is the old behaviour', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
const text = 'One very long sentence that has not finished yet and contains no blank line';
|
||||
assert.equal(settledMarkdownLength(text, 0), 0);
|
||||
});
|
||||
|
||||
test('the search resumes from what was already consumed', (t) => {
|
||||
const { settledMarkdownLength } = ui(t);
|
||||
// A boundary already passed must never be returned again, or the same text
|
||||
// would be appended twice.
|
||||
const text = 'A.\n\nB.\n\nC still';
|
||||
const first = settledMarkdownLength(text, 0);
|
||||
assert.ok(settledMarkdownLength(text, first) >= first);
|
||||
assert.equal(settledMarkdownLength('A.\n\nB still', 4), 4);
|
||||
});
|
||||
|
||||
// ---- what lands in the bubble ----------------------------------------------
|
||||
|
||||
test('the settled part is real markdown while the tail is still plain text', (t) => {
|
||||
const ctx = ui(t);
|
||||
const bubble = bubbleFor(ctx);
|
||||
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE + '\n\n| Half | tab', []);
|
||||
const settled = bubble.querySelector('.assistant-stream-settled');
|
||||
assert.ok(settled.querySelector('table'), 'the finished table should be a table');
|
||||
assert.equal(settled.querySelectorAll('table tbody tr').length, 2);
|
||||
const tail = bubble.querySelector('.assistant-streaming-text');
|
||||
assert.equal(tail.textContent.trim(), '| Half | tab');
|
||||
assert.equal(tail.querySelector('table'), null, 'the unfinished row must not be parsed');
|
||||
});
|
||||
|
||||
test('no raw-text <pre> dump, however long or table-heavy the answer', (t) => {
|
||||
const ctx = ui(t);
|
||||
const bubble = bubbleFor(ctx);
|
||||
// Both of the old bail-out conditions at once: over 3500 characters, and far
|
||||
// more than eight pipe rows. This used to render as one black <pre>.
|
||||
const long = 'Intro.\n\n' + (TABLE + '\n\n').repeat(60) + 'Trailing sen';
|
||||
assert.ok(long.length > 3500);
|
||||
ctx.renderStreamingInto(bubble, long, []);
|
||||
assert.ok(bubble.querySelectorAll('table').length >= 20);
|
||||
assert.equal(bubble.querySelector('pre'), null, 'nothing should fall back to a <pre> dump');
|
||||
assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'Trailing sen');
|
||||
});
|
||||
|
||||
test('settled content is appended, never re-rendered', (t) => {
|
||||
const ctx = ui(t);
|
||||
const bubble = bubbleFor(ctx);
|
||||
ctx.renderStreamingInto(bubble, 'First.\n\nSecond stil', []);
|
||||
const settled = bubble.querySelector('.assistant-stream-settled');
|
||||
const firstNode = settled.firstChild;
|
||||
ctx.renderStreamingInto(bubble, 'First.\n\nSecond.\n\nThird stil', []);
|
||||
assert.equal(settled.firstChild, firstNode, 'the first block should be the same node, untouched');
|
||||
assert.equal(settled.children.length, 2);
|
||||
assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'Third stil');
|
||||
});
|
||||
|
||||
test('a diagram that has drawn is not torn down by the next block', async (t) => {
|
||||
const ctx = ui(t);
|
||||
const bubble = bubbleFor(ctx);
|
||||
let renders = 0;
|
||||
ctx.window.mermaid = {
|
||||
initialize() {},
|
||||
render(id, src) { renders++; return Promise.resolve({ svg: '<svg data-src="' + src + '"></svg>' }); }
|
||||
};
|
||||
ctx.renderStreamingInto(bubble, '```mermaid\ngraph TD; A-->B;\n```\n\nNext par', []);
|
||||
const diagram = bubble.querySelector('[data-mermaid]');
|
||||
assert.ok(diagram, 'the diagram placeholder should be in the settled part');
|
||||
// Several more blocks arrive, each re-running renderEmbeddedBlocks.
|
||||
ctx.renderStreamingInto(bubble, '```mermaid\ngraph TD; A-->B;\n```\n\nNext paragraph.\n\nAnd another.\n\nStill go', []);
|
||||
await new Promise(resolve => setImmediate(resolve)); // ensureMermaid resolves on a microtask
|
||||
assert.equal(bubble.querySelector('[data-mermaid]'), diagram, 'same element');
|
||||
assert.equal(diagram.getAttribute('data-drawn'), '1');
|
||||
assert.equal(renders, 1, 'mermaid.render should be called once per diagram, not once per block');
|
||||
});
|
||||
|
||||
test('a chart canvas is constructed once — a second Chart on it throws', (t) => {
|
||||
const ctx = ui(t);
|
||||
const bubble = bubbleFor(ctx);
|
||||
const built = [];
|
||||
ctx.window.Chart = function (c, cfg) { built.push(cfg); };
|
||||
const chart = '```chart\n{"type":"bar","data":{"labels":["a"],"datasets":[{"data":[1]}]}}\n```';
|
||||
ctx.renderStreamingInto(bubble, chart + '\n\nA para', []);
|
||||
ctx.renderStreamingInto(bubble, chart + '\n\nA paragraph.\n\nAnother.\n\nStill go', []);
|
||||
if (bubble.querySelector('canvas[data-chart]')) assert.equal(built.length, 1);
|
||||
});
|
||||
|
||||
test('an empty stream shows the placeholder and holds no stale state', (t) => {
|
||||
const ctx = ui(t);
|
||||
const bubble = bubbleFor(ctx);
|
||||
ctx.renderStreamingInto(bubble, '', []);
|
||||
assert.match(bubble.innerHTML, /Generating answer/);
|
||||
assert.equal(bubble.assistantStreamState, null);
|
||||
});
|
||||
|
||||
test('the final render drops the streaming state, so no frame appends into a detached node', (t) => {
|
||||
const ctx = ui(t);
|
||||
const bubble = bubbleFor(ctx);
|
||||
ctx.renderStreamingInto(bubble, 'First.\n\nSecond stil', []);
|
||||
assert.ok(bubble.assistantStreamState);
|
||||
ctx.fillMessageBubble(bubble, 'assistant', 'First.\n\nSecond, finished.', [], null, false);
|
||||
assert.equal(bubble.assistantStreamState, null);
|
||||
assert.equal(bubble.querySelector('.assistant-stream-settled'), null);
|
||||
});
|
||||
|
||||
test('the tail is styled as prose, not left to the browser monospace default', (t) => {
|
||||
const css = read('public/css/assistant.css');
|
||||
assert.match(css, /\.assistant-streaming-text \{[^}]*white-space:pre-wrap/);
|
||||
assert.match(css, /\.assistant-streaming-text \{[^}]*color:var\(--g700\)/);
|
||||
});
|
||||
Loading…
Reference in a new issue