diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 27c9e050..75aff3df 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -691,23 +691,28 @@ import { renderEmbeddedBlocks(state.settled); } var tail = text.slice(state.consumed); + // The unfinished block is rendered as markdown too, re-parsed each frame. + // It used to be shown as raw text until its blank line arrived, which is + // the flash of asterisks and pipes the reader sees on every paragraph — + // Open WebUI never shows it because it re-renders the whole message on + // every token. The tail is one block, so re-parsing it costs nothing. + // + // A table gets its rows completed first (partialTableMarkdown): rows that + // arrive next have no header of their own and cannot parse alone. A block + // inside an unclosed code fence stays text: a half-written mermaid or + // chart would be handed to its renderer on every frame and fail there. var growing = partialTableMarkdown(tail); - if (growing) { - // A table is the one block worth rendering before it is finished, and - // the one the blank-line rule serves worst: a table contains no blank - // lines, so the whole of it stays raw until the line *after* it arrives, - // and then snaps into place. That is the markdown flash, on exactly the - // content where it is most obvious. - // - // Re-rendered each frame rather than appended: the rows that arrive next - // have no header of their own, so they cannot be parsed as a separate - // chunk. The tail is small, so re-parsing it is cheap. - state.tail.className = 'assistant-stream-partial'; - state.tail.innerHTML = renderAssistantBubbleHtml(growing, sources, false); + var openFence = (tail.match(/```/g) || []).length % 2 === 1; + // A header row with nothing under it yet is a table markdown-it would + // happily draw — two lines of pipes and an empty body. Not yet. + var headerOnly = !growing && TABLE_ROW.test(tail.trim().split('\n')[0] || ''); + if (tail && !openFence && !headerOnly) { + state.tail.className = 'assistant-stream-tail assistant-stream-partial'; + state.tail.innerHTML = renderAssistantBubbleHtml(growing || tail, sources, false); renderEmbeddedBlocks(state.tail); state.tail.hidden = false; } else { - state.tail.className = 'assistant-streaming-text'; + state.tail.className = 'assistant-stream-tail assistant-streaming-text'; state.tail.textContent = tail; state.tail.hidden = !tail; } diff --git a/server.js b/server.js index eef8c57a..7b17087c 100644 --- a/server.js +++ b/server.js @@ -253,12 +253,14 @@ app.use(express.static(path.join(__dirname, 'public'), { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - } else if (filePath.match(/\.(js|css)$/)) { - // Short cache for JS/CSS — 1 hour - res.setHeader('Cache-Control', 'public, max-age=3600'); - } else if (filePath.indexOf('/components/') !== -1) { - // Component HTML — 1 hour cache - res.setHeader('Cache-Control', 'public, max-age=3600'); + } else if (filePath.match(/\.(js|css)$/) || filePath.indexOf('/components/') !== -1) { + // Revalidate every time, never serve stale. ES module imports + // (clinicalAssistant.js → ./assistant/citations.js) carry no version + // query, so the hour of max-age this used to set meant a deployment's + // renderer sat behind the old one in every open browser for an hour — + // "not fixed" from a reader who had just watched it deploy. no-cache + // keeps the ETag round-trip (a 304, a few bytes) and drops the wait. + res.setHeader('Cache-Control', 'no-cache'); } } })); diff --git a/test/assistant-streaming-blocks.test.js b/test/assistant-streaming-blocks.test.js index 9c0c4b07..2d51c100 100644 --- a/test/assistant-streaming-blocks.test.js +++ b/test/assistant-streaming-blocks.test.js @@ -116,14 +116,14 @@ test('the search resumes from what was already consumed', (t) => { // ---- what lands in the bubble ---------------------------------------------- -test('the settled part is real markdown while the tail is still plain text', (t) => { +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-streaming-text'); + 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'); }); @@ -138,7 +138,7 @@ test('no raw-text
dump, however long or table-heavy the answer', (t) => {
ctx.renderStreamingInto(bubble, long, []);
assert.ok(bubble.querySelectorAll('table').length >= 20);
assert.equal(bubble.querySelector('pre'), null, 'nothing should fall back to a dump');
- assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'Trailing sen');
+ assert.equal(bubble.querySelector('.assistant-stream-tail').textContent.trim(), 'Trailing sen');
});
test('settled content is appended, never re-rendered', (t) => {
@@ -150,7 +150,7 @@ test('settled content is appended, never re-rendered', (t) => {
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');
+ 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) => {
@@ -266,13 +266,29 @@ test('the finished table settles, and is not left rendered twice', (t) => {
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.
+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\nA sentence still bei', []);
+ ctx.renderStreamingInto(bubble, 'First.\n\n**Severe:** a sentence [1] still bei', [{ number: 1, title: 'S' }]);
assert.equal(bubble.querySelector('table'), null);
- assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, 'A sentence still bei');
+ 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 --------------------------------------------