Measured against Open WebUI on the same question, sampling every 250ms: it never showed a raw pipe. A real <table> appeared with 2 rows at t+3.25s and grew to 4, then 6, as they arrived. The blank-line rule alone cannot do that, and a table is the case it serves worst: a markdown table contains no blank line, so the whole of it stayed in the tail as plain text until the line *after* it landed, then snapped into place. That is the markdown flash, on exactly the content where it shows most. So a table is now rendered while it is still arriving: once there is a header, the |---| rule and one body row, the tail is rendered as markdown rather than held as text, and every complete row that follows joins it. A half-typed row is left out and appears a frame later, which is what makes the table grow a row at a time. Re-rendered each frame rather than appended, unlike a settled block: the rows arriving next carry no header of their own, so they cannot be parsed as a separate chunk. The tail is small, so the cost is small. The status was the other half of that trace: Open WebUI keeps a skeleton beside the streaming content until the answer is done. Ours removed the status the instant the first token landed — the moment it becomes most useful, because the answer is arriving *and* the assistant is still working, drawing a figure or completing a cut-off reply. It now sits above the partial answer, shimmering, until the final render replaces the bubble. And the wait itself says what is happening. Retrieval finishes before the stream opens — deliberately, so a bad request still returns an error rather than a stream — which makes that first line the only thing a reader has during the slowest part. "Retrieving and synthesizing references" described the software; "Searching the clinical library" describes the work. Mutation-tested: dropping the table rule, rendering the incomplete trailing row, showing a table with no body row, or failing to restore the status each frame all fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
318 lines
15 KiB
JavaScript
318 lines
15 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\)/);
|
|
});
|
|
|
|
// ---- a table, rendered while it is still arriving ---------------------------
|
|
//
|
|
// Measured against Open WebUI on the same question, sampling every 250ms: it
|
|
// never showed a raw pipe, because it rendered a real <table> with 2 rows at
|
|
// t+3.25s and grew it to 4 then 6 as they arrived.
|
|
//
|
|
// The blank-line rule alone cannot do that. A markdown table contains no blank
|
|
// line, so the whole of it stayed in the tail as plain text until the line
|
|
// *after* it landed — the markdown flash, on the content where it shows most.
|
|
|
|
const TABLE_HEAD = '| Drug | Dose |\n| --- | --- |';
|
|
|
|
test('a table renders from its first complete row, not when it finishes', (t) => {
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |', []);
|
|
const table = bubble.querySelector('table');
|
|
assert.ok(table, 'an unfinished table should already be a table');
|
|
assert.equal(bubble.querySelectorAll('tbody tr').length, 1);
|
|
assert.doesNotMatch(bubble.textContent, /\|\s*---/, 'no raw pipe syntax on screen');
|
|
});
|
|
|
|
test('rows appear as they arrive', (t) => {
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
const rows = ['| Adrenaline | 10 mcg/kg |', '| Amiodarone | 5 mg/kg |', '| Atropine | 20 mcg/kg |'];
|
|
const counts = [];
|
|
for (let i = 1; i <= rows.length; i++) {
|
|
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD + '\n' + rows.slice(0, i).join('\n'), []);
|
|
counts.push(bubble.querySelectorAll('tbody tr').length);
|
|
}
|
|
assert.deepEqual(counts, [1, 2, 3], 'the table should grow a row at a time');
|
|
});
|
|
|
|
test('a half-typed row waits rather than rendering as half a row', (t) => {
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |\n| Amiod', []);
|
|
assert.equal(bubble.querySelectorAll('tbody tr').length, 1, 'only the complete row is shown');
|
|
assert.doesNotMatch(bubble.textContent, /Amiod$/);
|
|
});
|
|
|
|
test('a header with no body row yet is not yet a table', (t) => {
|
|
// Two lines of pipes and nothing under them is not something to show.
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD, []);
|
|
assert.equal(bubble.querySelector('table'), null);
|
|
});
|
|
|
|
test('the finished table settles, and is not left rendered twice', (t) => {
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |', []);
|
|
ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |\n\nAfter the table.\n\nStill go', []);
|
|
assert.equal(bubble.querySelectorAll('table').length, 1, 'one table, not one settled and one in the tail');
|
|
assert.equal(bubble.querySelector('.assistant-stream-settled').querySelectorAll('table').length, 1);
|
|
});
|
|
|
|
test('ordinary text still streams as text', (t) => {
|
|
// The table rule must not swallow the plain case.
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.renderStreamingInto(bubble, 'First.\n\nA sentence still bei', []);
|
|
assert.equal(bubble.querySelector('table'), null);
|
|
assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'A sentence still bei');
|
|
});
|
|
|
|
// ---- what is still happening --------------------------------------------
|
|
|
|
test('the status stays above the answer while it streams', (t) => {
|
|
// It used to be removed the moment the first token landed, which is when it
|
|
// becomes most useful: the answer is arriving and the assistant is still
|
|
// working.
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.showStreamStatus(bubble, 'Generating image…');
|
|
ctx.renderStreamingInto(bubble, 'An answer in progress', []);
|
|
ctx.showStreamStatus(bubble, 'Generating image…');
|
|
const strip = bubble.querySelector('.assistant-stream-status');
|
|
assert.ok(strip);
|
|
assert.equal(strip.textContent, 'Generating image…');
|
|
assert.equal(bubble.firstChild, strip, 'it sits above the answer, not inside it');
|
|
});
|
|
|
|
test('every render re-asserts the status, so it survives the whole stream', (t) => {
|
|
// Calling showStreamStatus in a test proves the function works, not that the
|
|
// stream uses it. renderProvisional sets the bubble's contents each frame, so
|
|
// it has to put the strip back or the strip only ever lives for one frame.
|
|
const src = read('public/js/clinicalAssistant.js');
|
|
const fn = src.slice(src.indexOf('function renderProvisional'), src.indexOf('function handleEvent'));
|
|
assert.match(fn, /renderStreamingInto\(bubble, partial, streamSources\);[\s\S]{0,400}showStreamStatus\(bubble, streamStatus\)/,
|
|
'renderProvisional does not restore the status strip after rendering');
|
|
// And a status event updates it immediately rather than waiting for a token.
|
|
assert.match(src, /streamStatus = data\.message[\s\S]{0,160}showStreamStatus\(bubble, streamStatus\)/);
|
|
});
|
|
|
|
test('an empty status removes the strip rather than leaving a gap', (t) => {
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.showStreamStatus(bubble, 'Working…');
|
|
ctx.showStreamStatus(bubble, '');
|
|
assert.equal(bubble.querySelector('.assistant-stream-status'), null);
|
|
});
|
|
|
|
test('the wait names what is happening, not what the software is doing', (t) => {
|
|
const ui_ = read('public/js/clinicalAssistant.js');
|
|
assert.match(ui_, /appendLoadingMessage\('Searching the clinical library'/);
|
|
assert.doesNotMatch(ui_, /Retrieving and synthesizing references/);
|
|
});
|