diff --git a/public/css/assistant.css b/public/css/assistant.css index 429cefd2..fc25d0c1 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -182,6 +182,20 @@ dark flash people saw while a table streamed. pre-wrap so the line breaks of a half-finished table are visible rather than collapsed into one run. */ .assistant-streaming-text { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; color:var(--g700); } +/* A table still arriving. Rendered as a real table from its first complete row + — the block the blank-line rule served worst, because a table has no blank + line inside it and so stayed raw until the line after it landed. */ +.assistant-stream-partial { margin:0; } +/* What is still happening, above the answer rather than instead of it. The + shimmer is the same one the thinking bubble uses, so the two read as one + state rather than two. */ +.assistant-stream-status { + margin:0 0 8px; font-size:12px; color:var(--g500); + background:linear-gradient(90deg,var(--g100),var(--g50),var(--g100)); + background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; + border-radius:6px; padding:4px 8px; display:inline-block; +} +@media (prefers-reduced-motion: reduce) { .assistant-stream-status { animation:none; } } .assistant-stream-settled:not(:empty) + .assistant-streaming-text:not([hidden]) { margin-top:10px; } .assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; } .assistant-source-modal { position:fixed; inset:0; z-index:10000; background:rgba(15,23,42,.72); display:flex; align-items:center; justify-content:center; padding:24px; } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 39184705..824943f8 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -453,7 +453,11 @@ import { regenerateMode = false; // consumed by this request activeAssistantRequest = request; setBusy(true, 'Looking up sources...'); - var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...'); + // Retrieval finishes before the stream opens — deliberately, so a bad + // request still returns an error rather than a stream — which means this + // line is the only thing the reader has during the slowest part of the + // wait. It should name what is happening, not describe the software. + var loading = appendLoadingMessage('Searching the clinical library', 'Looking for sources that answer this…'); request.loading = loading; request.accept = function() { if (request.accepted || request.cancelled) return; @@ -519,6 +523,7 @@ import { if (request && request.accept) request.accept(); var partial = ''; + var streamStatus = ''; var streamSources = []; var finalData = null; var lastRender = 0; @@ -535,13 +540,20 @@ import { bubble.classList.remove('assistant-thinking'); bubble.assistantSources = streamSources; renderStreamingInto(bubble, partial, streamSources); + // The status used to vanish the instant the first token landed, which is + // the moment it becomes most useful: the answer is arriving *and* the + // assistant is still working — drawing a figure, completing a cut-off + // reply. It now sits above the partial answer until the answer is done. + showStreamStatus(bubble, streamStatus); var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; } function handleEvent(type, data) { if (type === 'status') { - updateLoadingMessage(loading, data.message || 'Working...'); + streamStatus = data.message || 'Working...'; + updateLoadingMessage(loading, streamStatus); + if (bubble) showStreamStatus(bubble, streamStatus); return; } if (type === 'sources') { @@ -621,7 +633,7 @@ import { bubble.innerHTML = ''; var state = { settled: document.createElement('div'), - tail: document.createElement('p'), + tail: document.createElement('div'), consumed: 0 }; state.settled.className = 'assistant-stream-settled'; @@ -632,6 +644,26 @@ import { return state; } + /** + * A shimmering line above the partial answer saying what is still happening. + * + * Its own node at the top of the bubble rather than part of the streamed + * content, so it can be replaced on every status without disturbing a word of + * the answer, and removed at the end without leaving a gap. The final render + * rebuilds the bubble, which takes it with it. + */ + function showStreamStatus(bubble, message) { + var strip = bubble.querySelector('.assistant-stream-status'); + if (!message) { if (strip) strip.remove(); return; } + if (!strip) { + strip = document.createElement('div'); + strip.className = 'assistant-stream-status'; + strip.setAttribute('role', 'status'); + bubble.insertBefore(strip, bubble.firstChild); + } + strip.textContent = message; + } + function renderStreamingInto(bubble, text, sources) { if (!text) { bubble.assistantStreamState = null; @@ -651,8 +683,53 @@ import { renderEmbeddedBlocks(state.settled); } var tail = text.slice(state.consumed); - state.tail.textContent = tail; - state.tail.hidden = !tail; + 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); + renderEmbeddedBlocks(state.tail); + state.tail.hidden = false; + } else { + state.tail.className = 'assistant-streaming-text'; + state.tail.textContent = tail; + state.tail.hidden = !tail; + } + } + + var TABLE_ROW = /^\s*\|.*\|\s*$/; + var TABLE_RULE = /^\s*\|[\s:|-]*-{2,}[\s:|-]*\|\s*$/; + + /** + * The part of an unfinished tail that is already a readable table, or ''. + * + * A markdown table is parseable from the moment it has a header row, the + * |---| rule beneath it, and one body row — every complete row after that + * only adds to it. So the answer is the tail up to its last *complete* row: a + * half-typed row is left out and appears a frame later, which is what makes + * the table grow a row at a time instead of arriving all at once. + */ + function partialTableMarkdown(tail) { + var lines = String(tail).split('\n'); + var rule = -1; + for (var i = 1; i < lines.length; i++) { + if (TABLE_RULE.test(lines[i]) && TABLE_ROW.test(lines[i - 1])) { rule = i; break; } + } + if (rule === -1) return ''; // no header and rule yet + var last = -1; + for (var j = rule + 1; j < lines.length; j++) { + if (TABLE_ROW.test(lines[j])) last = j; + } + if (last === -1) return ''; // a rule but no body row: nothing to show yet + return lines.slice(0, last + 1).join('\n'); } /** diff --git a/test/assistant-streaming-blocks.test.js b/test/assistant-streaming-blocks.test.js index 819a94e5..20f69e2b 100644 --- a/test/assistant-streaming-blocks.test.js +++ b/test/assistant-streaming-blocks.test.js @@ -205,3 +205,114 @@ test('the tail is styled as prose, not left to the browser monospace default', ( 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 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/); +});