fix: preserve saved clinical tables and per-turn citation targets
This commit is contained in:
parent
cfaf8e957b
commit
b0bebe6970
7 changed files with 560 additions and 124 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' : '' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
287
test/assistant-saved-tables.test.js
Normal file
287
test/assistant-saved-tables.test.js
Normal 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, '&').replace(/</g, '<') + '</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]');
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue