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