One line of changing text is now a list the reader can follow: Analyzing the question → Searching the clinical library → Found 7 sources → Writing the answer, plus any step the server reports (looking at an image, drawing one, completing a cut-off reply). Steps are added when the stream says work began and ticked when it says it ended, so the list is a record of the real work, not an animation on a timer; nothing delays the answer. The one hand-off the server cannot signal — retrieval runs before the stream opens — is done by the stylesheet, not a JS timer, so the autosave debounce keeps its clock to itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
361 lines
18 KiB
JavaScript
361 lines
18 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.markdownit = require('markdown-it');
|
|
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 and the tail is markdown too, short of a table row', (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-stream-tail');
|
|
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-stream-tail').textContent.trim(), '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-stream-tail').textContent.trim(), '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('the unfinished paragraph is rendered, not shown as raw markdown', (t) => {
|
|
// The flash the reader used to see: "**Severe:** seizures [8]" as literal
|
|
// asterisks and brackets until the paragraph's blank line arrived.
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.renderStreamingInto(bubble, 'First.\n\n**Severe:** a sentence [1] still bei', [{ number: 1, title: 'S' }]);
|
|
assert.equal(bubble.querySelector('table'), null);
|
|
const tail = bubble.querySelector('.assistant-stream-tail');
|
|
assert.ok(tail.querySelector('strong'), 'bold is bold while the paragraph is still arriving');
|
|
assert.ok(tail.querySelector('a.assistant-cite'), 'a citation is a chip while the paragraph is still arriving');
|
|
assert.equal(tail.textContent.trim(), 'Severe: a sentence 1 still bei');
|
|
});
|
|
|
|
test('a block inside an unclosed code fence stays text until the fence closes', (t) => {
|
|
// A half-written mermaid diagram handed to its renderer every frame would
|
|
// fail every frame; monospaced text is what the reader expects there anyway.
|
|
const ctx = ui(t);
|
|
const bubble = bubbleFor(ctx);
|
|
ctx.renderStreamingInto(bubble, 'First.\n\n```mermaid\ngraph TD\n A --> B', []);
|
|
const tail = bubble.querySelector('.assistant-stream-tail');
|
|
assert.ok(tail.classList.contains('assistant-streaming-text'));
|
|
assert.equal(tail.textContent, '```mermaid\ngraph TD\n A --> B');
|
|
assert.equal(tail.querySelector('pre, .mermaid'), null);
|
|
});
|
|
|
|
// ---- 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/);
|
|
});
|
|
|
|
|
|
// ---- the steps while the answer is prepared --------------------------------
|
|
|
|
test('the wait is a list of steps, ticked by what the stream reports', (t) => {
|
|
const ctx = ui(t);
|
|
const row = ctx.appendLoadingMessage('Searching the clinical library', 'Looking for sources that answer this…');
|
|
const steps = () => Array.from(row.querySelectorAll('.assistant-progress-step')).map(li =>
|
|
[li.dataset.step, li.textContent.trim(), li.classList.contains('is-active') ? 'active' : li.classList.contains('is-done') ? 'done' : 'pending']);
|
|
assert.deepEqual(steps(), [['analyze', 'Analyzing the question', 'active'], ['search', 'Searching the clinical library', 'active']]);
|
|
// The hand-off between the two is the stylesheet's, not a timer's.
|
|
assert.ok(row.querySelector('[data-step="analyze"]').classList.contains('is-brief'));
|
|
assert.ok(row.querySelector('[data-step="search"]').classList.contains('is-queued'));
|
|
|
|
// The sources event ends the search with its result.
|
|
ctx.progressSources(row, 7);
|
|
assert.deepEqual(steps().slice(0, 2), [['analyze', 'Analyzing the question', 'done'], ['search', 'Found 7 sources', 'done']]);
|
|
|
|
// The server's own statuses become steps; the running one is ticked when the next begins.
|
|
ctx.updateLoadingMessage(row, 'Generating answer...');
|
|
assert.deepEqual(steps()[2], ['write', 'Writing the answer', 'active']);
|
|
ctx.updateLoadingMessage(row, 'Generating image…');
|
|
assert.deepEqual(steps().slice(2), [['write', 'Writing the answer', 'done'], ['Generating image…', 'Generating image…', 'active']]);
|
|
// Nothing in the list is on a JS timer; the stylesheet owns the motion.
|
|
assert.match(read('public/css/assistant.css'), /\.assistant-progress-step\.is-active \.assistant-progress-mark \{[^}]*animation:spin/);
|
|
});
|