From b0bebe6970f7900c5a19d1365daa75d2f674c689 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 05:58:12 +0200 Subject: [PATCH] fix: preserve saved clinical tables and per-turn citation targets --- public/css/assistant.css | 2 + public/js/assistant/citations.js | 199 ++++++++++++--- public/js/assistant/export.js | 46 ++-- public/js/clinicalAssistant.js | 134 +++++----- src/utils/clinicalConversation.js | 12 +- test/assistant-saved-tables.test.js | 287 ++++++++++++++++++++++ test/clinical-release-integration.test.js | 4 +- 7 files changed, 560 insertions(+), 124 deletions(-) create mode 100644 test/assistant-saved-tables.test.js 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 = '
Rendering graph...
'; + else if (lang === 'chart' || lang === 'chartjs') html = ''; + else 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 '
Rendering graph...
'; - if (block.lang === 'chart' || block.lang === 'chartjs') return ''; - 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(/]*)?>/g, '
') + .replace(/]*)?>/g, '
') .replace(/<\/table>/g, '
'); } export function renderCitationLinks(html, sources, options) { var opts = options || {}; - return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) { + return String(html || '').replace(/<(pre|code|a)\b[^>]*>[\s\S]*?<\/\1>|<[^>]*>|\[((?:\d+\s*,\s*)*\d+)\]/gi, function (match, tag, cluster) { + if (!cluster) return match; var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; }); if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match; return nums.map(function (n) { @@ -51,7 +66,7 @@ export function renderCitationLinks(html, sources, options) { var page = source && (source.page || source.page_number || source.pageNumber); var label = 'Source ' + n + ': ' + title + (page ? ', page ' + page : ''); var text = opts.citationLabel === 'number' ? String(n) : 'src'; - return '' + text + ''; + return '' + text + ''; }).join(' '); }); } @@ -93,9 +108,125 @@ function formatCitationCluster(nums) { return '[' + nums.join(', ') + ']'; } -export function normalizeMarkdownText(text) { - return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(String(text || '') - .replace(/\r\n/g, '\n') +// Keep Markdown structure and literal syntax out of prose cleanups. The installed +// parser identifies complete blocks; the no-parser path only shields pipe lines. +function textProtection(text, kind) { + var prefix = '\uE000' + (kind || 'markdown') + ':'; + while (text.indexOf(prefix) !== -1) prefix += '\uE000'; + var values = []; + var originals = []; + var pattern = new RegExp(prefix + '(\\d+)\uE001', 'g'); + return { + hold: function(value, original) { + originals.push(typeof original === 'string' ? original : value); + return prefix + (values.push(value) - 1) + '\uE001'; + }, + restore: function(value, raw) { + var previous; + do { + previous = value; + value = value.replace(pattern, function(_, i) { return (raw ? originals : values)[Number(i)]; }); + } while (value !== previous); + return value; + } + }; +} + +function protectBlocks(text, opts, hold, codeOnly) { + if (opts.marked && typeof opts.marked.lexer === 'function') { + return opts.marked.lexer(text, { gfm: true }).map(function(token) { + if (codeOnly) return token.type === 'code' ? hold(token.text, token.lang) + '\n\n' : token.raw; + if (['space', 'paragraph', 'heading'].includes(token.type)) return token.raw; + return hold(token.type === 'table' ? normalizeTableSourceCitationCells(stripTrailingMarkerLine(token.raw)) : + token.type === 'code' || token.type === 'html' ? token.raw : stripTrailingMarkerLine(token.raw)) + '\n\n'; + }).join(''); + } + if (opts.markdownIt && typeof opts.markdownIt.parse === 'function') { + var lines = text.split('\n'); + var ranges = opts.markdownIt.parse(text, {}).filter(function(token) { + return token.map && (codeOnly ? ['fence', 'code_block'] : + ['table_open', 'fence', 'code_block', 'html_block', 'blockquote_open', 'bullet_list_open', 'ordered_list_open']).includes(token.type); + }); + var end = 0; + ranges.forEach(function(token) { + if (token.map[0] < end) return; + end = token.map[1]; + var raw = lines.slice(token.map[0], end).join('\n'); + lines[token.map[0]] = codeOnly ? hold(token.content, token.info) : hold(token.type === 'table_open' ? normalizeTableSourceCitationCells(stripTrailingMarkerLine(raw)) : + ['fence', 'code_block', 'html_block'].includes(token.type) ? raw : stripTrailingMarkerLine(raw)); + for (var i = token.map[0] + 1; i < end; i++) lines[i] = ''; + }); + return lines.join('\n'); + } + text = text.replace(/^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)(?:^ {0,3}\1[^\n]*(?:\n|$)|(?![\s\S]))/gm, function(raw, fence, lang, code) { + return codeOnly ? hold(code, lang.trim()) : hold(raw); + }); + return text.replace(/(?:^(?: {4}|\t)[^\n]*(?:\n|$))+/gm, function(raw) { + return codeOnly ? hold(raw.replace(/^(?: {4}|\t)/gm, ''), '') : hold(raw); + }); +} + +function stripTrailingMarkerLine(text) { + return text.replace(/\n[ \t]*(?:\*\*|__|\*|_)[ \t]*\n?$/, '\n'); +} + +export function normalizeMarkdownText(text, options) { + var opts = options || {}; + text = String(text || '').replace(/\r\n/g, '\n'); + var protectedText = textProtection(text); + text = protectBlocks(text, opts, protectedText.hold); + // These spans must not become headings/lists or apparent legacy row boundaries. + text = text.replace(/(`+)[\s\S]*?\1|\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^$\n]+\$|!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/[^\s<>]+|\\\|/g, protectedText.hold); + text = text.split('\n').map(function(line) { + if (!/\|[ \t]*:?-{2,}:?[ \t]*\|/.test(line)) return line; + var recovered = recoverLegacyTableLine(line); + if (recovered !== null) return recovered; + // A standalone delimiter row belongs to existing multiline Markdown. + if (/^[ \t]*\|?[ :|\t-]+$/.test(line)) return line; + var start = line.indexOf('|'); + var raw = protectedText.restore(line.slice(start)); + var literal = typeof opts.onUnrecoverable === 'function' ? opts.onUnrecoverable(raw) : raw; + return line.slice(0, start) + '\n\n' + protectedText.hold(literal); + }).join('\n'); + text = protectBlocks(text, opts, protectedText.hold); + // Also leave incomplete/non-GFM pipe structures alone, rather than guessing. + text = text.replace(/^[^\n]*\|[^\n]*(?:\n[^\n]*\|[^\n]*)*/gm, function(block) { + return protectedText.hold(normalizeTableSourceCitationCells(block)); + }); + return protectedText.restore(normalizeProse(text)); +} + +// Only outer-pipe rows separated by "| |", with a header + alignment row and +// identical nonempty cell counts, survive whitespace collapse unambiguously. +// Empty cells, trailing prose, partial rows and mixed separators are not guessed. +function recoverLegacyTableLine(line) { + var start = line.indexOf('|'); + var prefix = line.slice(0, start); + var candidate = line.slice(start).trimEnd(); + if (start < 0 || !candidate.endsWith('|')) return null; + var rows = candidate.split(/(?<=\|)[ \t]+(?=\|)/); + if (rows.length < 3) return null; + var cells = rows.map(tableCells); + if (cells.some(function(row) { return row.length < 2 || row.some(function(cell) { return !cell; }); })) return null; + function separator(row) { return row.every(function(cell) { return /^:?-{2,}:?$/.test(cell); }); } + var output = []; + for (var i = 0; i < rows.length;) { + if (!cells[i + 1] || !separator(cells[i + 1]) || cells[i].length !== cells[i + 1].length || separator(cells[i])) return null; + var width = cells[i].length; + var end = i + 2; + while (end < rows.length && !(cells[end + 1] && separator(cells[end + 1]))) { + if (cells[end].length !== width || separator(cells[end])) return null; + end++; + } + if (end === i + 2) return null; + output.push(rows.slice(i, end).join('\n')); + i = end; + } + return (prefix.trim() ? prefix + '\n\n' : '') + output.join('\n\n'); +} + +function normalizeProse(text) { + return stripOrphanMarkdownMarkers(String(text || '') .replace(/([^\n])\n+\s*(\[(?:\d+\s*,\s*)*\d+\])\s*(?:\n+\s*([.,;:]))?(?=\s*(?:\n|$))/g, '$1 $2$3') .replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2') .replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2') @@ -104,8 +235,7 @@ export function normalizeMarkdownText(text) { .replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2') .replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2') .replace(/([^\n])\s+(-\s+(?:Mainstay|Medications|Hospitalization|Other therapies|Prevention|Short-acting|Anticholinergics|Systemic|Adjuncts|Long-term|Infants|Differentiating|Persistent|Severe|Need for|Inadequate)\b)/g, '$1\n$2') - .replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2') - .trim())); + .trim()); } export function normalizeTableSourceCitationCells(text) { @@ -222,16 +352,19 @@ function renderFallbackTable(lines) { } function tableCells(line) { - return String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function(cell) { return cell.trim(); }); + return String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?' + '

' + escapeHtml(heading) + '

' + '
Question: ' + escapeHtml(item.question || '') + '
' + - (summary ? '

Summary

' + renderMarkdown(summary, sources, { citationLabel: 'number' }) + '
' : '') + - '

Full Generated Answer

' + renderMarkdown(answer, sources, { citationLabel: 'number' }) + '
' + + (summary ? '

Summary

' + renderMarkdown(summary, sources, renderOptions) + '
' : '') + + '

Full Generated Answer

' + answerHtml + '
' + (refs ? '

References

    ' + refs + '
' : '') + ''; }).join(''); @@ -202,10 +205,10 @@ function inlineExportCss() { } function exportTableScrollCss() { - return '.assistant-table-scroll{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;margin:12px 0 18px;border:1px solid #e5e7eb;border-radius:10px;background:white}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:max-content;min-width:100%;max-width:none;margin:0;border:0;border-radius:0}.answer .assistant-table-scroll th,.answer .assistant-table-scroll td,#assistant-export-modal .answer .assistant-table-scroll th,#assistant-export-modal .answer .assistant-table-scroll td{min-width:120px;overflow-wrap:normal;word-break:normal}.assistant-table-scroll::after{content:"Swipe table";display:block;position:sticky;left:0;bottom:0;padding:3px 9px;font-size:10px;font-weight:700;color:#6b7280;background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0));pointer-events:none}@media print{.assistant-table-scroll{overflow:visible;border:0}.assistant-table-scroll::after{display:none}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:100%;max-width:100%}}'; + return '.answer [align=right],#assistant-export-modal .answer [align=right]{text-align:right}.answer [align=center],#assistant-export-modal .answer [align=center]{text-align:center}.assistant-table-scroll{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;margin:12px 0 18px;border:1px solid #e5e7eb;border-radius:10px;background:white}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:max-content;min-width:100%;max-width:none;margin:0;border:0;border-radius:0}.answer .assistant-table-scroll th,.answer .assistant-table-scroll td,#assistant-export-modal .answer .assistant-table-scroll th,#assistant-export-modal .answer .assistant-table-scroll td{min-width:120px;overflow-wrap:normal;word-break:normal}.assistant-table-scroll::after{content:"Swipe table";display:block;position:sticky;left:0;bottom:0;padding:3px 9px;font-size:10px;font-weight:700;color:#6b7280;background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0));pointer-events:none}@media print{.assistant-table-scroll{overflow:visible;border:0}.assistant-table-scroll::after{display:none}.answer .assistant-table-scroll table,#assistant-export-modal .answer .assistant-table-scroll table{width:100%;max-width:100%}}'; } -function collectExportItems(messages, lastAnswer, lastSources) { +function collectExportItems(messages, lastAnswer, lastSources, presentMessage) { var items = []; var pendingQuestion = ''; (messages || []).forEach(function (m) { @@ -215,11 +218,13 @@ function collectExportItems(messages, lastAnswer, lastSources) { } if (m.role !== 'assistant' || !m.content) return; if (isUtilityAssistantMessage(m.content)) return; + var display = typeof presentMessage === 'function' ? presentMessage(m) : { answer: m.content }; items.push({ question: pendingQuestion || 'Clinical question', heading: deriveExportHeading(pendingQuestion, items.length), summary: '', - answer: m.content, + answer: display.answer, + notice: display.notice, sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources }); pendingQuestion = ''; @@ -230,31 +235,16 @@ function collectExportItems(messages, lastAnswer, lastSources) { return items; } -function renderExportRefs(sources, sectionNumber, citedNumbers) { - var cited = citedNumbers && citedNumbers.length ? new Set(citedNumbers.map(String)) : null; - return (sources || []).filter(function (s, idx) { - var n = s.number || idx + 1; - return !cited || cited.has(String(n)); - }).map(function (s, idx) { +function renderExportRefs(sources, sectionNumber, cited) { + return (sources || []).map(function (s, idx) { var n = s.number || idx + 1; + if (cited.length && !cited.includes(String(n))) return ''; var title = s.title || s.resource || 'Untitled source'; var page = s.page || s.page_number || s.pageNumber; - return '
  • [' + n + '] ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.
  • '; + return '
  • [' + escapeHtml(n) + '] ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.
  • '; }).join(''); } -function extractCitedSourceNumbers(text) { - var found = new Set(); - String(text || '').replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, function (_, nums) { - nums.split(',').forEach(function (n) { - n = String(n || '').trim(); - if (n) found.add(n); - }); - return _; - }); - return Array.from(found).sort(function (a, b) { return Number(a) - Number(b); }); -} - function deriveExportHeading(question, idx) { var text = String(question || '').replace(/\s+/g, ' ').trim(); if (!text || /^(what|which|when|why|how|and|also|what dose\??|dose\??)$/i.test(text)) return 'Clinical Question ' + (idx + 1); @@ -267,6 +257,6 @@ function isUtilityAssistantMessage(content) { export function buildExportCacheKey(items, imageSrc) { return JSON.stringify({ items: (items || []).map(function (item) { - return { q: item.question, a: item.answer, s: (item.sources || []).map(function (s) { return [s.number, s.title, s.page]; }) }; + return { q: item.question, a: item.answer, notice: item.notice, s: (item.sources || []).map(function (s) { return [s.number, s.title, s.page]; }) }; }), image: imageSrc ? '1' : '' }); } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index e3b403d..0386eb0 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -33,7 +33,7 @@ import { var activeAssistantRequest = null; var conversationChars = null; var STREAM_MARKDOWN_LIMIT = 3500; - var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast }); + var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, presentMessage: savedMessagePresentation, showToast: window.showToast }); var imageStore = createAssistantImageStore(); document.addEventListener('tabChanged', function (e) { @@ -195,20 +195,6 @@ import { }; } - async function fetchAssistantResponse(payload, loading) { - updateLoadingMessage(loading, 'Looking up sources...'); - var data = await fetchAssistantFallback(payload); - setBusy(false, 'Ready'); - lastAnswer = data.answer || ''; - lastSources = data.sources || []; - replaceLoadingMessage(loading, lastAnswer, lastSources, data.suggestions || []); - renderSources(lastSources); - if (data.model) { - var label = document.getElementById('assistant-model-label'); - if (label) label.textContent = 'Chat: ' + data.model; - } - } - async function streamAssistantResponse(payload, loading, request) { var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined }); if (!response.ok || !response.body) { @@ -232,6 +218,7 @@ import { if (!bubble) return; loading.classList.remove('assistant-loading-msg'); bubble.classList.remove('assistant-thinking'); + bubble.assistantSources = streamSources; bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '

    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, '
    ') + '

    '; - } + function renderAssistantBubbleHtml(content, sources, rawHtml, options) { + return rawHtml ? sanitize(String(content || '')) : renderMarkdown(content, sources || [], options); + } + + function fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options) { + bubble.assistantSources = role === 'assistant' && Array.isArray(sources) ? sources : []; + bubble.innerHTML = role === 'assistant' ? renderAssistantBubbleHtml(content, sources, rawHtml, options) : escapeHtml(content); + if (role !== 'assistant' && options && options.notice) bubble.innerHTML += '

    ' + 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('
    ' + read('public/components/assistant.html') + '
    ', { url: 'https://example.test' }); + const window = dom.window; + const style = window.document.createElement('style'); + style.textContent = read('public/css/assistant.css'); + window.document.head.appendChild(style); + window.marked = parser; + window.DOMPurify = require('dompurify')(window); + window.matchMedia = () => ({ matches: true }); + const saves = []; + 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 body => { saves.push(JSON.parse(JSON.stringify(savedChatPayload(body)))); return { success: true }; } + }; + vm.createContext(context); + for (const file of ['assistant/citations.js', 'assistant/sources.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, document: window.document, saves, window }; +} +function rows(element) { return [...element.querySelectorAll('tbody tr')].map(row => [...row.cells].map(cell => cell.textContent.trim())); } +function bubble(app) { return app.document.querySelector('.assistant-msg.assistant .assistant-bubble'); } +function reopen(app, content, version = 2, lastAnswer = content) { + app.context.restoreSavedChat({ version, messages: [{ role: 'user', content: 'Synthetic question' }, { role: 'assistant', content, sources }], sources, lastAnswer }); +} + +test('raw v2 actual save/load and SSE/fallback/append/export share all table cells and source pages', async t => { + const app = ui(t); + const c = app.context; + for (const fallback of [false, true]) { + c.clearConversation(); + c.openAssistantStream = async () => new Response(fallback ? '' : 'event: done\ndata: ' + JSON.stringify({ answer: table, sources }) + '\n\n'); + c.fetchAssistantChat = async () => ({ success: true, answer: table, sources }); + const loading = c.appendLoadingMessage(); + await c.streamAssistantResponse({}, loading); + assert.deepEqual(rows(bubble(app)), [['Alpha', '1.25', 'A - B', 'src'], ['Beta', '2-4', 'unchanged', 'src']]); + assert.match(bubble(app).querySelector('.assistant-cite').title, /Source 2: Synthetic B, page 19/); + assert.equal(bubble(app).querySelectorAll('td')[1].getAttribute('align'), 'right'); + assert.equal(app.window.getComputedStyle(bubble(app).querySelectorAll('td')[1]).textAlign, 'right'); + assert.equal(bubble(app).querySelector('.assistant-table-scroll').getAttribute('tabindex'), '0'); + await c.saveCurrentChat(); + assert.equal(app.saves.at(-1).version, 2); + assert.equal(app.saves.at(-1).messages[0].content, table); + assert.equal(app.saves.at(-1).lastAnswer, table); + c.restoreSavedChat(app.saves.at(-1)); + assert.equal(rows(bubble(app)).length, 2); + c.exportAnswerPdf(); + const exported = app.document.querySelector('#assistant-export-modal'); + assert.deepEqual(rows(exported), [['Alpha', '1.25', 'A - B', '2'], ['Beta', '2-4', 'unchanged', '1']]); + for (const cite of exported.querySelectorAll('.assistant-cite')) { + assert.ok(exported.querySelector(cite.getAttribute('href')), 'export citation resolves to its own reference'); + } + c.clearConversation(); + c.appendMessage('assistant', table, sources); + assert.equal(rows(bubble(app)).length, 2); + } +}); + +test('legacy collapsed complete tables recover for display only, including multiple tables', async t => { + const app = ui(t); + const raw = collapse(table + '\n\n' + table.replace(/Alpha/g, 'Gamma').replace(/Beta/g, 'Delta')); + reopen(app, raw, 1); + assert.equal(bubble(app).querySelectorAll('table').length, 2); + assert.deepEqual(rows(bubble(app)).map(row => row[0]), ['Alpha', 'Beta', 'Gamma', 'Delta']); + assert.equal(app.context.messages[1].content, raw); + await app.context.saveCurrentChat(); + assert.equal(app.saves[0].messages[1].content, raw); + app.context.exportAnswerPdf(); + assert.equal(app.document.querySelectorAll('#assistant-export-modal table').length, 2); +}); + +test('long new tables survive serialization and both installed parsers without losing tail, caption, notes or units', async t => { + for (const parser of [marked, null]) { + const app = ui(t, parser); + if (!parser) app.window.markdownit = MarkdownIt; + const long = 'Table 1. Synthetic dose comparison\n\n' + table + '\n' + Array.from({ length: 700 }, (_, n) => '| Row ' + n + ' | 0.25 | A - B, 5-10 mg/kg | [2] |').join('\n') + '\n\nNote: No real clinical data.\n\n† Synthetic footnote.'; + const saved = JSON.parse(JSON.stringify(savedChatPayload({ messages: [{ role: 'assistant', content: long, sources }], lastAnswer: long, sources }))); + app.context.restoreSavedChat(saved); + assert.equal(rows(bubble(app)).length, 702); + assert.equal(rows(bubble(app)).at(-1)[0], 'Row 699'); + assert.match(bubble(app).textContent, /Table 1\. Synthetic dose comparison/); + assert.match(bubble(app).textContent, /† Synthetic footnote\./); + assert.equal(saved.messages[0].content, long); + } +}); + +test('code, math, escaped pipes and URLs cannot become lists or steal source-column cells', t => { + const app = ui(t); + const special = '| Item | Notes | Sources |\n| --- | --- | --- |\n| Alpha | `A - B [1]` and $x - y$ and \\(a - b\\) | 2 |\n| Beta | a\\|b and [URL](https://example.test/a-b?q=1%7C2) | [1] |'; + reopen(app, special); + assert.equal(rows(bubble(app)).length, 2); + assert.equal(rows(bubble(app))[0][1], 'A - B [1] and $x - y$ and \\(a - b\\)'); + assert.equal(rows(bubble(app))[1][1], 'a|b and URL'); + assert.equal(rows(bubble(app))[1][2], 'src'); + assert.equal(bubble(app).querySelector('code .assistant-cite'), null); + assert.equal(bubble(app).querySelector('a[href^="https:"]').getAttribute('href'), 'https://example.test/a-b?q=1%7C2'); + for (const literal of ['`' + collapse(table) + '`', '~~~md\n' + collapse(table) + '\n~~~', ' ' + collapse(table), '$$' + collapse(table) + '$$', '\\[' + collapse(table) + '\\]', 'https://example.test/' + collapse(table).replace(/ /g, '%20')]) { + reopen(app, literal, 1); + assert.equal(bubble(app).querySelectorAll('table').length, 0, literal); + assert.equal(bubble(app).querySelectorAll('li').length, 0, literal); + } +}); + +test('ambiguous, empty-cell and truncated legacy tables are unchanged and honestly limited', t => { + const app = ui(t); + for (const raw of [collapse(table).slice(0, -3), '| A | B | | --- | --- | | x | |', '| A | B | | --- | --- | | x | y | extra |']) { + reopen(app, raw, 1); + assert.equal(bubble(app).querySelectorAll('table').length, 0); + assert.ok(bubble(app).textContent.includes(raw)); + assert.match(bubble(app).textContent, /could not.*recover|cannot.*recover/i); + assert.equal(app.context.messages[1].content, raw); + } + const withLiterals = '| A | B | | --- | --- | | `x - y` and $a - b$ | [URL](https://example.test/) | incomplete'; + reopen(app, withLiterals, 1); + assert.ok(bubble(app).textContent.includes(withLiterals)); +}); + +test('legacy exact-limit prefix may display only retained lastAnswer, never manufacture missing content', async t => { + const app = ui(t); + const full = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | complete | [1] |').join('\n')); + assert.ok(full.length > 12000 && full.length < 30000); + const clipped = full.slice(0, 12000); + reopen(app, clipped, 1, full); + assert.equal(rows(bubble(app)).length, 452); + assert.match(bubble(app).textContent, /retained.*lastAnswer/i); + assert.equal(app.context.messages[1].content, clipped); + await app.context.saveCurrentChat(); + assert.equal(app.saves[0].messages[1].content, clipped); + assert.equal(app.saves[0].lastAnswer, full); + app.context.exportAnswerPdf(); + assert.equal(app.document.querySelectorAll('#assistant-export-modal tbody tr').length, 452); + for (const retained of [clipped, 'different ' + full]) { + reopen(app, clipped, 1, retained); + assert.match(bubble(app).textContent, /clipped|truncat/i); + assert.doesNotMatch(bubble(app).textContent, /Row 449/); + } + reopen(app, clipped, 2, full); + assert.doesNotMatch(bubble(app).textContent, /Row 449/, 'v2 does not infer truncation from an ordinary 12,000-character message'); +}); + +test('all render entrypoints fail closed on missing sanitizer, malicious HTML or parser errors', t => { + const app = ui(t); + const malicious = table + '\n\nbad'; + for (const mode of ['normal', 'no-sanitizer', 'throw']) { + if (mode === 'no-sanitizer') delete app.window.DOMPurify; + if (mode === 'throw') app.window.marked = { lexer: marked.lexer, parse() { throw new Error('Synthetic parser failure'); } }; + reopen(app, malicious); + app.context.exportAnswerPdf(); + assert.equal(app.document.querySelector('script, [onerror], a[href^="javascript:"]'), null); + assert.ok(bubble(app).textContent.includes('Alpha')); + assert.ok(app.document.querySelector('#assistant-export-modal').textContent.includes('Beta')); + } +}); + +test('legacy recovery refuses missing boundaries; supports independent lines and literal syntax inside complete rows', t => { + const app = ui(t); + const escaped = '| Name | Notes | Source |\n| :--- | ---: | --- |\n| A | a\\|b and `x\\|y` and $x - y$ | 2 |\n| B | https://example.test/a?b=1%7C2 | 1 |'; + reopen(app, collapse(escaped), 1); + assert.equal(rows(bubble(app)).length, 2); + assert.equal(rows(bubble(app))[0][1], 'a|b and x|y and $x - y$'); + assert.equal(rows(bubble(app))[0][2], 'src'); + reopen(app, collapse(table) + '\n\nCaption two\n\n' + collapse(table), 1); + assert.equal(bubble(app).querySelectorAll('table').length, 2); + for (const raw of ['| A | B | --- | --- | x | y |', '| A | B | | --- | --- | | x | y', '| A | B | | --- | --- | | x | y | trailing prose']) { + reopen(app, raw, 1); + assert.equal(bubble(app).querySelectorAll('table').length, 0); + assert.ok(bubble(app).textContent.includes(raw)); + assert.match(bubble(app).textContent, /could not safely recover/i); + } +}); + +test('math/code literals and sentinel-shaped input survive postprocessing without citation or HTML interpretation', t => { + const app = ui(t); + const expressions = []; + app.window.katex = { renderToString(expression) { expressions.push(expression); return '' + expression.replace(/&/g, '&').replace(/'; } }; + const raw = '| Item | Value | Sources |\n| --- | --- | --- |\n| Alpha | `a\\|b [2][1] $notmath$` and $x \\mid y [1]$ | 2 |\n| Beta | \\(a - b\\) | 1 |'; + reopen(app, raw); + assert.equal(rows(bubble(app)).length, 2); + assert.equal(bubble(app).querySelector('code').textContent, 'a|b [2][1] $notmath$'); + assert.equal(bubble(app).querySelector('code .katex, .katex .assistant-cite'), null); + assert.deepEqual(expressions, ['x \\mid y [1]', 'a - b']); + assert.equal(rows(bubble(app))[1][2], 'src'); + for (const raw of ['$$\n' + table + '\n$$', '~~~md\n' + table + '\n[1][2] $code$\n~~~', '\uE000html:0\uE001 and `\uE000markdown:0\uE001 [2][1]`']) { + reopen(app, raw); + assert.equal(bubble(app).querySelectorAll('table, .assistant-cite').length, 0); + } + reopen(app, '[URL](https://example.test/[1][2]) and A - B and 5-10 mg/kg'); + assert.equal(decodeURIComponent(bubble(app).querySelector('a').getAttribute('href')), 'https://example.test/[1][2]'); + assert.match(bubble(app).textContent, /A - B and 5-10 mg\/kg/); + reopen(app, '[URL](https://example.test/$notmath$)'); + assert.equal(bubble(app).querySelector('a').getAttribute('href'), 'https://example.test/$notmath$'); + assert.ok(!expressions.includes('notmath')); +}); + +test('legacy loss provenance and retained raw extension survive v2 re-save and follow-up with strict guards', async t => { + const app = ui(t); + const full = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | retained | [1] |').join('\n')); + const clipped = full.slice(0, 12000); + reopen(app, clipped, 1, full); + app.context.appendMessage('user', 'Follow-up'); + app.context.appendMessage('assistant', 'New answer', sources); + app.context.lastAnswer = 'New answer'; + await app.context.saveCurrentChat(); + const saved = app.saves[0]; + assert.equal(saved.version, 2); + assert.equal(saved.messages[1].content, clipped); + assert.equal(saved.messages[1].retainedAnswer, full); + assert.equal(saved.messages[1].legacyClipped, true); + app.context.restoreSavedChat(saved); + assert.equal(rows(bubble(app)).length, 452); + assert.equal(app.context.messages[1].content, clipped); + assert.equal(app.context.conversationSize(''), saved.messages.reduce((n, m) => n + m.content.length, 0), 'display-only extension is not silently sent as inference history'); + for (const extension of [clipped, 'not a prefix ' + full, full + 'x'.repeat(30000), full + '\n', undefined]) { + const result = savedChatPayload({ messages: [{ role: 'assistant', content: clipped, legacyClipped: true, retainedAnswer: extension }] }); + assert.equal(result.messages[0].retainedAnswer, undefined); + assert.equal(result.messages[0].content, clipped); + } + const differentSources = { version: 1, messages: [{ role: 'assistant', content: clipped, sources: [{ title: 'Other', page: 99 }] }], sources, lastAnswer: full }; + app.context.restoreSavedChat(differentSources); + assert.doesNotMatch(bubble(app).textContent, /Row 449/); + reopen(app, clipped, 1, clipped + 'x'.repeat(30000 - clipped.length)); + assert.match(bubble(app).textContent, /30,000 characters.*absent content cannot be recovered/); +}); + +test('parser-unavailable literals stay inert, and embedded chart initialization retains an attached bubble', t => { + const app = ui(t, null); + for (const raw of [' ' + collapse(table), '~~~md\n' + table + '\n~~~', '$$' + table + '$$']) { + reopen(app, raw, 1); + assert.equal(bubble(app).querySelectorAll('table, .assistant-cite').length, 0); + assert.match(bubble(app).textContent, /Alpha/); + } + let rendered = 0; + app.window.HTMLCanvasElement.prototype.getContext = function() { + assert.equal(this.isConnected, true); + return { canvas: this }; + }; + app.window.Chart = function(context, config) { + assert.equal(context.canvas.isConnected, true); + assert.equal(config.type, 'bar'); + rendered++; + }; + app.context.appendMessage('assistant', '```chart\n{"type":"bar","data":{"labels":["[1]"],"datasets":[]}}\n```'); + assert.equal(rendered, 1); +}); + +test('clicking reused citation numbers selects the original turn source without changing saved history', async t => { + const app = ui(t); + const first = [{ number: 1, title: 'First turn reference', page: 11, excerpt: 'First turn context.' }]; + const second = [{ number: 1, title: 'Second turn reference', page: 23, excerpt: 'Second turn context.' }]; + app.context.restoreSavedChat({ version: 2, messages: [ + { role: 'user', content: 'First question' }, + { role: 'assistant', content: 'First answer. [1]', sources: first }, + { role: 'user', content: 'Second question' }, + { role: 'assistant', content: 'Second answer. [1]', sources: second } + ], lastAnswer: 'Second answer. [1]', sources: second }); + app.document.addEventListener('click', app.context.onAssistantDocumentClick); + const citations = app.document.querySelectorAll('#assistant-messages .assistant-cite'); + assert.equal(citations.length, 2); + citations[0].click(); + assert.match(app.document.querySelector('#assistant-source-1').textContent, /First turn reference.*page 11/is); + assert.doesNotMatch(app.document.querySelector('#assistant-source-1').textContent, /Second turn reference/); + citations[1].click(); + assert.match(app.document.querySelector('#assistant-source-1').textContent, /Second turn reference.*page 23/is); + citations[0].click(); + await app.context.saveCurrentChat(); + assert.deepEqual(app.saves[0].messages[1].sources, first); + assert.deepEqual(app.saves[0].messages[3].sources, second); + assert.deepEqual(app.saves[0].sources, second, 'viewing an older source does not replace the latest retrieval map'); + assert.equal(app.saves[0].messages[1].content, 'First answer. [1]'); +}); diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js index 065d30a..f97c4da 100644 --- a/test/clinical-release-integration.test.js +++ b/test/clinical-release-integration.test.js @@ -97,7 +97,9 @@ test('native admin and assistant modules retain budget, table/source identity an assert.equal(element.querySelector('script, [onerror], [onload], a[href^="javascript:"]'), null); if (purified) { assert.equal(element.querySelectorAll('.assistant-table-scroll table tbody tr').length, 1); - assert.equal(element.querySelector('.assistant-cite').getAttribute('href'), '#assistant-source-1'); + const target = element.id === 'assistant-export-modal' ? '#ref-1-1' : '#assistant-source-1'; + assert.equal(element.querySelector('.assistant-cite').getAttribute('href'), target); + assert.ok(document.querySelector(target), 'citation target exists in the displayed chat/export'); assert.match(element.querySelector('td').textContent, /Synthetic/); } else { assert.equal(element.querySelector('table, a, img, svg'), null, 'fallback is escaped text, not raw HTML');