fix: preserve saved clinical tables and per-turn citation targets

This commit is contained in:
Daniel 2026-09-07 05:58:12 +02:00
parent cfaf8e957b
commit b0bebe6970
7 changed files with 560 additions and 124 deletions

View file

@ -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; }

View file

@ -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 = '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(encodeURIComponent(code)) + '">Rendering graph...</div>';
else if (lang === 'chart' || lang === 'chartjs') html = '<canvas class="assistant-chart" data-chart="' + escapeAttr(code) + '"></canvas>';
else html = '<pre><code>' + escapeHtml(code) + '</code></pre>';
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('<pre>' + escapeHtml(raw) + '</pre>', 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 '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(encodeURIComponent(block.code)) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
html = embedded.restore(html);
html = wrapTables(html);
if (limited) html += '<p class="assistant-render-notice" role="note">Could not safely recover this flattened table. Stored text is shown unchanged; missing cells or rows cannot be recovered.</p>';
if (opts.notice) html += '<p class="assistant-render-notice" role="note">' + escapeHtml(opts.notice) + '</p>';
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll"><table$1>')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table$1>')
.replace(/<\/table>/g, '</table></div>');
}
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 '<a class="assistant-cite" href="#assistant-source-' + n + '" data-source-number="' + n + '" title="' + escapeHtml(label) + '" aria-label="' + escapeAttr(label) + '">' + text + '</a>';
return '<a class="assistant-cite" href="#' + escapeAttr(opts.citationTargetPrefix || 'assistant-source-') + n + '" data-source-number="' + n + '" title="' + escapeHtml(label) + '" aria-label="' + escapeAttr(label) + '">' + text + '</a>';
}).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(/(?<!\\)\|/).map(function(cell) { return cell.trim(); });
}
export function renderLatexText(text, katex) {
if (!katex) return text;
export function renderLatexText(text, katex, hold) {
if (!katex && !hold) return text;
function render(raw, expr, display) {
var html = katex ? safeKatex(katex, expr, display) : escapeHtml(raw);
return hold ? hold(html, raw) : html;
}
return String(text || '')
.replace(/\$\$([\s\S]+?)\$\$/g, function(_, expr) { return safeKatex(katex, expr, true); })
.replace(/\\\[([\s\S]+?)\\\]/g, function(_, expr) { return safeKatex(katex, expr, true); })
.replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(katex, expr, false); })
.replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(katex, expr, false); });
.replace(/\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\$([^$\n]+?)\$|\\\((.+?)\\\)/g, function(raw, dollars, brackets, inline, parens) {
return render(raw, dollars || brackets || inline || parens, !!(dollars || brackets));
});
}
function safeKatex(katex, expr, displayMode) {

View file

@ -16,7 +16,7 @@ export function createAssistantExporter(options) {
if (typeof options.showToast === 'function') options.showToast('No answer to export', 'error');
return;
}
var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || []);
var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || [], options.presentMessage);
var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || '');
if (exportCacheKey === cacheKey && exportCacheItems) {
showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || '');
@ -114,13 +114,16 @@ export function createAssistantExporter(options) {
var heading = item.heading || deriveExportHeading(item.question, idx);
var summary = item.summary || '';
var answer = item.answer || '';
var citedNumbers = extractCitedSourceNumbers([summary, answer].filter(Boolean).join('\n\n'));
var refs = renderExportRefs(sources, idx + 1, citedNumbers);
var renderOptions = { citationLabel: 'number', citationTargetPrefix: 'ref-' + (idx + 1) + '-', notice: item.notice };
var answerHtml = renderMarkdown(answer, sources, renderOptions);
// Read rendered citations so bare source-column numbers also get refs.
var cited = Array.from(answerHtml.matchAll(/data-source-number="(\d+)"/g), function(match) { return match[1]; });
var refs = renderExportRefs(sources, idx + 1, cited);
return '<section class="export-section">' +
'<h2>' + escapeHtml(heading) + '</h2>' +
'<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' +
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources, { citationLabel: 'number' }) + '</div>' : '') +
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources, { citationLabel: 'number' }) + '</div>' +
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources, renderOptions) + '</div>' : '') +
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + answerHtml + '</div>' +
(refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
'</section>';
}).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 '<li id="ref-' + sectionNumber + '-' + n + '"><strong>[' + n + ']</strong> ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.</li>';
return '<li id="ref-' + sectionNumber + '-' + escapeAttr(n) + '"><strong>[' + escapeHtml(n) + ']</strong> ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.</li>';
}).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' : '' });
}

View file

@ -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) : '<p class="assistant-muted">Generating answer...</p>';
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 '<p>' + escapeHtml(String(content || '')).replace(/\n/g, '<br>') + '</p>';
}
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 += '<p role="note">' + escapeHtml(options.notice) + '</p>';
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 '<pre>' + escapeHtml(String(md || '')) + '</pre>' +
'<p role="note">Formatting unavailable; retained text is shown unchanged.</p>' +
(opts.notice ? '<p role="note">' + escapeHtml(opts.notice) + '</p>' : '');
}
}
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() {

View file

@ -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');

View file

@ -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('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { 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\n<img src=x onerror="alert(1)"><script>alert(2)</script><a href="javascript:alert(3)">bad</a>';
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 '<span class="katex">' + expression.replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</span>'; } };
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]');
});

View file

@ -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');