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
207 lines
9.9 KiB
JavaScript
207 lines
9.9 KiB
JavaScript
// 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\)/);
|
|
});
|