The old renderer rewrote the text: it found "[n]" with regexes, renumbered them, and swapped the result back in — which broke inside `arr[2][1]`, inside HTML attributes, and whenever two turns disagreed about what "[3]" meant. It also had a fallback markdown renderer of its own for when the rewrite produced something markdown-it would not parse. Now "[n]" is an inline rule registered on the same markdown-it instance that renders everything else. The parser decides what is prose and what is code, a link, or a URL, so the rule never sees "[1]" inside a code span, and it steps aside for "[1](url)". Math is two more rules on the same parser instead of a regex pre-pass, so "$" inside a URL is no longer math. Identity vs display: the stored "[n]" and each card's id are the source's identity (sourceNumber) and are never rewritten. The number a reader sees is the order of first appearance, computed at render time from the token stream (orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat re-opens pointing at the same cards it was saved with. Stored messages and sources are untouched; export and the modal resolve by identity. Translated HTML gets the same links through a TreeWalker over text nodes (linkCitationsInHtml) rather than a regex over markup. Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable), renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert token output; harnesses that render for real are given a parser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
319 lines
15 KiB
JavaScript
319 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.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 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/);
|
|
});
|