diff --git a/public/css/assistant.css b/public/css/assistant.css index c9fb246..79ecdab 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -38,6 +38,8 @@ .assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; } .assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; } .assistant-bubble th, .assistant-bubble td { overflow-wrap:normal; word-break:normal; min-width:120px; } +.assistant-bubble [align="right"] { text-align:right; } +.assistant-bubble [align="center"] { text-align:center; } .assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); } .assistant-bubble tr:last-child td { border-bottom:0; } .assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; } diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index 2ab9948..7b285c1 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -1,15 +1,35 @@ export function renderAssistantMarkdown(md, sources, options) { var opts = options || {}; - var codeBlocks = []; - var text = String(md || '').replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) { - var idx = codeBlocks.length; - codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code }); - return '\n@@CODEBLOCK_' + idx + '@@\n'; - }); - text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]'))); - text = renderLatexText(text, opts.katex); - text = normalizeAdjacentCitationClusters(text, sources || []); + var text = String(md || ''); + var embedded = textProtection(text, 'html'); + // Literal/embedded content bypasses prose, math and citation rewriting, then + // rejoins the parsed HTML before the single sanitization boundary. + text = protectBlocks(text, opts, function(code, lang) { + lang = String(lang || '').toLowerCase(); + var html; + // Percent-encoding keeps Mermaid arrows intact through DOMPurify. + if (lang === 'mermaid') html = '
' + escapeHtml(code) + '';
+ return embedded.hold(html);
+ }, true);
+ var inlineCode = textProtection(text, 'code');
+ text = text.replace(/(`+)[\s\S]*?\1/g, inlineCode.hold);
+ var links = textProtection(text, 'links');
+ text = text.replace(/!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/[^\s<>]+|<[^>]*>/g, links.hold);
+ text = text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]');
+ text = renderLatexText(text, opts.katex, embedded.hold);
+ var limited = false;
+ text = normalizeMarkdownText(text, Object.assign({}, opts, {
+ onUnrecoverable: function(raw) {
+ limited = true;
+ raw = inlineCode.restore(links.restore(embedded.restore(raw, true)));
+ return embedded.hold('' + escapeHtml(raw) + '', raw); + } + })); + text = links.restore(normalizeAdjacentCitationClusters(text, sources || [])); + text = inlineCode.restore(text); var html; if (opts.marked && typeof opts.marked.parse === 'function') { html = opts.marked.parse(text, { breaks: true, gfm: true }); @@ -20,29 +40,24 @@ export function renderAssistantMarkdown(md, sources, options) { } html = renderCitationLinks(html, sources || [], opts); - html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) { - var block = codeBlocks[Number(idx)] || { lang: '', code: '' }; - // Percent-encoded: DOMPurify strips an attribute whose value contains "-->", - // which every mermaid flowchart has, leaving the diagram stuck on its - // placeholder. Encoding keeps the value intact; the reader decodes it. - if (block.lang === 'mermaid') return '
' + escapeHtml(block.code) + '';
- });
+ html = embedded.restore(html);
html = wrapTables(html);
+ if (limited) html += 'Could not safely recover this flattened table. Stored text is shown unchanged; missing cells or rows cannot be recovered.
'; + if (opts.notice) html += '' + escapeHtml(opts.notice) + '
'; return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html; } function wrapTables(html) { return String(html || '') - .replace(/Generating answer...
'; renderEmbeddedBlocks(bubble); var wrap = document.getElementById('assistant-messages'); @@ -371,48 +358,28 @@ import { if (!bubble) return; row.classList.remove('assistant-loading-msg'); bubble.classList.remove('assistant-thinking'); - bubble.innerHTML = renderAssistantBubbleHtml(content, sources || [], rawHtml); - if (suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions)); - renderEmbeddedBlocks(bubble); + fillMessageBubble(bubble, 'assistant', content, sources, suggestions, rawHtml); var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; messages.push({ role: 'assistant', content: content, sources: sources || [] }); updateConversationBudget(); } - function renderAssistantBubbleHtml(content, sources, rawHtml) { - if (rawHtml) return sanitize(String(content || '')); - try { - return renderMarkdown(content, sources || []); - } catch (e) { - console.warn('[clinical-assistant] markdown render failed:', e && e.message ? e.message : e); - return '' + escapeHtml(String(content || '')).replace(/\n/g, '
') + '
' + escapeHtml(options.notice) + '
'; + if (role === 'assistant' && suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions)); + renderEmbeddedBlocks(bubble); } function appendMessage(role, content, sources, suggestions, rawHtml) { - var wrap = document.getElementById('assistant-messages'); - if (!wrap) return; - var empty = wrap.querySelector('.assistant-empty'); - if (empty) empty.remove(); - - messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] }); - var row = document.createElement('div'); - row.className = 'assistant-msg ' + role; - var label = document.createElement('div'); - label.className = 'assistant-msg-label'; - label.textContent = role === 'user' ? 'You' : 'Assistant'; - var bubble = document.createElement('div'); - bubble.className = 'assistant-bubble'; - bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : (role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content)); - if (role === 'assistant' && suggestions && suggestions.length) { - bubble.appendChild(renderSuggestionButtons(suggestions)); - } - row.appendChild(label); - row.appendChild(bubble); - wrap.appendChild(row); - renderEmbeddedBlocks(bubble); - wrap.scrollTop = wrap.scrollHeight; + var row = appendMessageNode(role, content, sources, suggestions, rawHtml); + if (row) messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] }); return row; } @@ -435,14 +402,22 @@ import { function renderMarkdown(md, sources, options) { var opts = options || {}; - return renderAssistantMarkdown(md, sources || [], { - marked: window.marked, - markdownIt: getMarkdownRenderer(), - katex: window.katex, - mathJax: window.MathJax, - sanitize: sanitize, - citationLabel: opts.citationLabel - }); + try { + return renderAssistantMarkdown(md, sources || [], { + marked: window.marked, + markdownIt: getMarkdownRenderer(), + katex: window.katex, + sanitize: sanitize, + citationLabel: opts.citationLabel, + citationTargetPrefix: opts.citationTargetPrefix, + notice: opts.notice + }); + } catch (e) { + console.warn('[clinical-assistant] markdown render failed:', e && e.message ? e.message : e); + return '' + escapeHtml(String(md || '')) + '' + + '
Formatting unavailable; retained text is shown unchanged.
' + + (opts.notice ? '' + escapeHtml(opts.notice) + '
' : ''); + } } function getMarkdownRenderer() { @@ -569,6 +544,12 @@ import { } function onAssistantDocumentClick(e) { + var citation = e.target.closest('#assistant-messages .assistant-cite'); + if (citation) { + var bubble = citation.closest('.assistant-bubble'); + if (bubble && Array.isArray(bubble.assistantSources)) renderSources(bubble.assistantSources); + return; // The native anchor navigates to the matching source in the refreshed panel. + } var loadBtn = e.target.closest('[data-assistant-load-chat]'); if (loadBtn) { e.preventDefault(); @@ -803,15 +784,27 @@ import { function restoreSavedChat(payload) { messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) { - return { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] }; + var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] }; + if ((payload.version !== 2 || m.legacyClipped === true) && message.content.length === 12000 && !/[\r\n]/.test(message.content)) { + message.legacyClipped = true; + if (message.role === 'assistant' && isRetainedLegacyAnswer(message.content, m.retainedAnswer)) message.retainedAnswer = m.retainedAnswer; + } + return message; }) : []; lastSources = Array.isArray(payload.sources) ? payload.sources : []; lastAnswer = String(payload.lastAnswer || lastAssistantMessage(messages) || ''); + var finalMessage = messages[messages.length - 1]; + if (finalMessage && finalMessage.role === 'assistant' && finalMessage.legacyClipped && + (!finalMessage.sources.length || JSON.stringify(finalMessage.sources) === JSON.stringify(lastSources)) && + isRetainedLegacyAnswer(finalMessage.content, lastAnswer)) finalMessage.retainedAnswer = lastAnswer; lastGeneratedImageSrc = String(payload.generatedImage || ''); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = ''; - messages.forEach(function (m) { appendMessageNode(m.role, m.content, m.sources && m.sources.length ? m.sources : lastSources); }); + messages.forEach(function (m) { + var display = savedMessagePresentation(m); + appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display); + }); wrap.scrollTop = wrap.scrollHeight; } renderSources(lastSources); @@ -823,9 +816,27 @@ import { updateConversationBudget(); } - function appendMessageNode(role, content, sources) { + function isRetainedLegacyAnswer(content, retained) { + return typeof retained === 'string' && retained.length > content.length && retained.length <= 30000 && + !/[\r\n]/.test(retained) && retained.startsWith(content); + } + + function savedMessagePresentation(message) { + if (!message.legacyClipped) return { answer: message.content, notice: '' }; + return { + answer: message.retainedAnswer || message.content, + notice: message.retainedAnswer ? + 'Showing the retained lastAnswer that exactly extends this legacy clipped message; the stored transcript is unchanged.' + + (message.retainedAnswer.length === 30000 ? ' That retained answer may also have been clipped at 30,000 characters; absent content cannot be recovered.' : '') : + 'This legacy message may have been clipped at 12,000 characters. Missing content cannot be recovered from the saved text.' + }; + } + + function appendMessageNode(role, content, sources, suggestions, rawHtml, options) { var wrap = document.getElementById('assistant-messages'); if (!wrap) return; + var empty = wrap.querySelector('.assistant-empty'); + if (empty) empty.remove(); var row = document.createElement('div'); row.className = 'assistant-msg ' + role; var label = document.createElement('div'); @@ -833,11 +844,12 @@ import { label.textContent = role === 'user' ? 'You' : 'Assistant'; var bubble = document.createElement('div'); bubble.className = 'assistant-bubble'; - bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content); row.appendChild(label); row.appendChild(bubble); wrap.appendChild(row); - renderEmbeddedBlocks(bubble); + fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options); + wrap.scrollTop = wrap.scrollHeight; + return row; } function deriveChatTitle() { diff --git a/src/utils/clinicalConversation.js b/src/utils/clinicalConversation.js index 60c4a86..2511d79 100644 --- a/src/utils/clinicalConversation.js +++ b/src/utils/clinicalConversation.js @@ -87,7 +87,17 @@ function savedImage(image) { function savedChatPayload(body) { const messages = validateMessages(body.messages).map(function(message, index) { - return { ...message, sources: savedSources(body.messages[index].sources) }; + const original = body.messages[index]; + const copy = { ...message, sources: savedSources(original.sources) }; + // Preserve legacy loss provenance and an existing retained raw field across + // a v2 re-save/follow-up; neither is inference history or replacement text. + if (original.legacyClipped === true && message.content.length === 12000 && !/[\r\n]/.test(message.content)) { + copy.legacyClipped = true; + if (message.role === 'assistant' && typeof original.retainedAnswer === 'string' && original.retainedAnswer.length > 12000 && + original.retainedAnswer.length <= 30000 && !/[\r\n]/.test(original.retainedAnswer) && + original.retainedAnswer.startsWith(message.content)) copy.retainedAnswer = original.retainedAnswer; + } + return copy; }); if (body.lastAnswer !== undefined && typeof body.lastAnswer !== 'string') { throw failure('Invalid saved answer.', 400, 'INVALID_SAVED_CHAT'); diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js new file mode 100644 index 0000000..bf9ed8d --- /dev/null +++ b/test/assistant-saved-tables.test.js @@ -0,0 +1,287 @@ +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 MarkdownIt = require('markdown-it'); +const { savedChatPayload } = require('../src/utils/clinicalConversation'); +const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); +const sources = [{ title: 'Synthetic A', page: 7 }, { title: 'Synthetic B', page_number: 19 }]; +const table = '| Item | Value (mg/kg) | Notes | Sources |\n| :--- | ---: | :---: | --- |\n| Alpha | 1.25 | A - B | 2 |\n| Beta | 2-4 | unchanged | [1] |'; +const collapse = text => text.replace(/\s+/g, ' ').trim(); + +function ui(t, parser = marked) { + const dom = new JSDOM('