367 lines
15 KiB
JavaScript
367 lines
15 KiB
JavaScript
export function renderAssistantMarkdown(md, sources, options) {
|
|
var opts = options || {};
|
|
var text = stripOrphanMarkdownMarkers(repairTabSeparatedTables(repairMarkdownTables(normalizeMarkdownText(md))));
|
|
var codeBlocks = [];
|
|
text = text.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 = stripSourcesSection(text);
|
|
text = renderLatexText(text, opts.katex);
|
|
text = normalizeAdjacentCitationClusters(text, sources || []);
|
|
|
|
var html;
|
|
if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
|
|
html = opts.markdownIt.render(text);
|
|
} else if (opts.marked && typeof opts.marked.parse === 'function') {
|
|
html = opts.marked.parse(text, { breaks: true, gfm: true });
|
|
} else {
|
|
html = fallbackMarkdown(text);
|
|
}
|
|
|
|
html = renderCitationLinks(html, sources || [], opts);
|
|
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
|
|
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
|
|
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(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>';
|
|
});
|
|
|
|
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
|
|
}
|
|
|
|
export function renderCitationLinks(html, sources, options) {
|
|
var opts = options || {};
|
|
return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
|
|
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) {
|
|
var source = sources[n - 1];
|
|
var title = source ? source.title || source.resource || 'Source' : 'Source';
|
|
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>';
|
|
}).join(' ');
|
|
});
|
|
}
|
|
|
|
export function normalizeAdjacentCitationClusters(text, sources) {
|
|
var available = Array.isArray(sources) ? sources : [];
|
|
var normalized = String(text || '').replace(/((?:\[(?:\d+\s*,\s*)*\d+\]\s*)+)\[((?:\d+\s*,\s*)*\d+)(?=$|[.,;:])/g, function(match, completeClusters, trailingCluster) {
|
|
var nums = citationNumbers(completeClusters).concat(parseCitationCluster(trailingCluster));
|
|
if (!allCitationsAvailable(nums, available)) return match;
|
|
return formatCitationCluster(nums);
|
|
});
|
|
|
|
return normalized.replace(/(?:\[(?:\d+\s*,\s*)*\d+\]\s*){2,}/g, function(match) {
|
|
var nums = citationNumbers(match);
|
|
if (!allCitationsAvailable(nums, available)) return match;
|
|
return formatCitationCluster(nums);
|
|
});
|
|
}
|
|
|
|
function citationNumbers(text) {
|
|
var clusters = String(text || '').match(/\[((?:\d+\s*,\s*)*\d+)\]/g) || [];
|
|
var nums = [];
|
|
clusters.forEach(function(cluster) {
|
|
nums = nums.concat(parseCitationCluster(cluster.slice(1, -1)));
|
|
});
|
|
return nums;
|
|
}
|
|
|
|
function parseCitationCluster(cluster) {
|
|
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isInteger(n) && n > 0; });
|
|
}
|
|
|
|
function allCitationsAvailable(nums, sources) {
|
|
return nums.length > 0 && nums.every(function(n) { return sources[n - 1]; });
|
|
}
|
|
|
|
function formatCitationCluster(nums) {
|
|
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
|
|
return '[' + nums.join(', ') + ']';
|
|
}
|
|
|
|
export function stripSourcesSection(text) {
|
|
return String(text || '')
|
|
.replace(/\s*(?:-{3,}\s*)?(?:#{1,6}\s*)?(?:Sources|References)\s*:?\s*[\s\S]*$/i, '')
|
|
.trim();
|
|
}
|
|
|
|
export function normalizeMarkdownText(text) {
|
|
return stripOrphanMarkdownMarkers(String(text || '')
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
|
|
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
|
|
.replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
|
|
.replace(/(\[(?:\d+\s*,\s*)*\d+\]\.)\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
|
|
.replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
|
|
.replace(/(\[(?:\d+\s*,\s*)*\d+\])(?=\s*(?:[A-Z][A-Za-z]+\s+){1,4}(?:deficits?|distress|apnoea|apnea|vomiting|seizures?|signs?|symptoms?|criteria|indications?|risk|oxygen|saturation|dehydration|lethargy|toxicity)\b)/g, '$1\n')
|
|
.replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2')
|
|
.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*Aspect\s*\|)/gi, '$1\n\n$2')
|
|
.replace(/([^\n])\s+(\|\s*[-:]+\s*\|)/g, '$1\n$2')
|
|
.replace(/\s+(\|\s*[^\n|]+\s*\|\s*[^\n|]+\s*\|)/g, '\n$1')
|
|
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2')
|
|
.trim());
|
|
}
|
|
|
|
export function stripOrphanMarkdownMarkers(text) {
|
|
return String(text || '').replace(/(?:\n|^)\s*(?:\*\*|__|\*|_)\s*$/g, '').trim();
|
|
}
|
|
|
|
export function repairTabSeparatedTables(text) {
|
|
var lines = String(text || '').split('\n');
|
|
var out = [];
|
|
var i = 0;
|
|
while (i < lines.length) {
|
|
if (!/\t/.test(lines[i]) || i + 1 >= lines.length || !/\t/.test(lines[i + 1])) {
|
|
out.push(lines[i]);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (out.length && out[out.length - 1].trim()) out.push('');
|
|
var tableLines = [];
|
|
while (i < lines.length && shouldKeepTabTableLine(lines[i], tableLines.length)) {
|
|
tableLines.push(lines[i]);
|
|
i += 1;
|
|
}
|
|
var header = tableLines[0].split('\t').map(function(cell) { return cell.trim(); });
|
|
var rows = tabTableRows(tableLines.slice(1), header.length);
|
|
out.push('| ' + header.join(' | ') + ' |');
|
|
out.push('| ' + header.map(function() { return '---'; }).join(' | ') + ' |');
|
|
rows.forEach(function(cells) {
|
|
out.push('| ' + cells.map(cleanTableCell).join(' | ') + ' |');
|
|
});
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
|
|
function shouldKeepTabTableLine(line, count) {
|
|
if (/\t/.test(line)) return true;
|
|
if (!count || !String(line || '').trim()) return false;
|
|
if (/^\s*(#{1,6}\s+|References\b|Sources\b)/i.test(line)) return false;
|
|
return /^\s*(?:\d+\s*){1,4}$/.test(line) || /^[^.!?]{1,90}$/.test(line);
|
|
}
|
|
|
|
function tabTableRows(lines, width) {
|
|
var rows = [];
|
|
var pending = null;
|
|
lines.forEach(function(line) {
|
|
var cells = String(line || '').split('\t').map(function(cell) { return cell.trim(); });
|
|
if (cells.length === 1 && pending) {
|
|
pending[pending.length - 1] = [pending[pending.length - 1], cells[0]].filter(Boolean).join(' ');
|
|
return;
|
|
}
|
|
if (pending && cells.length >= width && looksCitationCell(cells[0]) && cells[width - 1]) {
|
|
pending[pending.length - 1] = [pending[pending.length - 1], cells[0]].filter(Boolean).join(' ');
|
|
rows.push(normalizeTabRow(pending, width));
|
|
pending = [cells[width - 1]];
|
|
return;
|
|
}
|
|
if (pending && cells.length >= width) {
|
|
rows.push(normalizeTabRow(pending, width));
|
|
pending = null;
|
|
}
|
|
if (cells.length >= width) {
|
|
rows.push(normalizeTabRow(cells, width));
|
|
return;
|
|
}
|
|
if (pending) {
|
|
pending[pending.length - 1] = [pending[pending.length - 1], cells.join(' ')].filter(Boolean).join(' ');
|
|
} else {
|
|
pending = cells;
|
|
}
|
|
});
|
|
if (pending) rows.push(normalizeTabRow(pending, width));
|
|
return rows;
|
|
}
|
|
|
|
function normalizeTabRow(cells, width) {
|
|
cells = cells.slice();
|
|
while (cells.length < width) cells.push('');
|
|
if (width === 3 && looksCitationCell(cells[0]) && cells[2]) {
|
|
return [cells[2], '', cells[0]];
|
|
}
|
|
if (width === 3 && /\b(\d+\s*){1,4}\s+(?:Nonoperative|Surgical|Diagnostic|Recurrence|Initial|Further)\b/i.test(cells[1] || '')) {
|
|
var split = splitCitationPrefix(cells[1]);
|
|
cells[1] = split.citation;
|
|
if (split.rest && cells[2]) cells[2] = split.rest + ': ' + cells[2];
|
|
}
|
|
return cells.slice(0, width);
|
|
}
|
|
|
|
function splitCitationPrefix(text) {
|
|
var m = String(text || '').match(/^((?:\d+\s*){1,4})\s+(.+)$/);
|
|
return m ? { citation: m[1].trim(), rest: m[2].trim() } : { citation: text, rest: '' };
|
|
}
|
|
|
|
function looksCitationCell(text) {
|
|
return /^(?:\d+\s*){1,5}$/.test(String(text || '').trim());
|
|
}
|
|
|
|
function cleanTableCell(cell) {
|
|
return String(cell || '').replace(/(?:^|\s+)If you need[\s\S]*$/i, '').trim();
|
|
}
|
|
|
|
export function repairMarkdownTables(text) {
|
|
var lines = String(text || '').split('\n');
|
|
var repaired = [];
|
|
var i = 0;
|
|
while (i < lines.length) {
|
|
if (isSplitTableHeader(lines, i)) {
|
|
lines[i] = mergeSplitTableRows(lines[i], lines[i + 1]);
|
|
lines.splice(i + 1, 1);
|
|
}
|
|
if (!isPotentialTableHeader(lines, i)) {
|
|
repaired.push(lines[i]);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
var expected = tableCells(lines[i]).length;
|
|
if (repaired.length && repaired[repaired.length - 1].trim()) repaired.push('');
|
|
repaired.push(lines[i], normalizeTableSeparator(lines[i + 1], expected));
|
|
i += 2;
|
|
|
|
while (i < lines.length) {
|
|
var row = lines[i];
|
|
if (!row.trim()) { i += 1; continue; }
|
|
if (!/^\s*\|/.test(row)) break;
|
|
|
|
var merged = row;
|
|
var guard = 0;
|
|
while (tableCells(merged).length < expected && i + 1 < lines.length && /^\s*\|/.test(lines[i + 1]) && guard < 4) {
|
|
merged = mergeSplitTableRows(merged, lines[i + 1]);
|
|
i += 1;
|
|
guard += 1;
|
|
}
|
|
repaired.push(trimTableCellsToExpected(merged, expected));
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
return repaired.join('\n');
|
|
}
|
|
|
|
function isPotentialTableHeader(lines, idx) {
|
|
return idx + 1 < lines.length && /^\s*\|.*\|\s*$/.test(lines[idx]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[idx + 1]);
|
|
}
|
|
|
|
function isSplitTableHeader(lines, idx) {
|
|
return idx + 2 < lines.length && /^\s*\|.*\|?\s*$/.test(lines[idx]) && /^\s*\|.*\|\s*$/.test(lines[idx + 1]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[idx + 2]);
|
|
}
|
|
|
|
function normalizeTableSeparator(line, expected) {
|
|
var cells = tableCells(line);
|
|
while (cells.length < expected) cells.push('---');
|
|
return '| ' + cells.slice(0, expected).map(function(cell) { return /^:?-{2,}:?$/.test(cell) ? cell : '---'; }).join(' | ') + ' |';
|
|
}
|
|
|
|
function mergeSplitTableRows(left, right) {
|
|
var a = tableCells(left);
|
|
var b = tableCells(right);
|
|
if (!a.length) return right;
|
|
a[a.length - 1] = (a[a.length - 1] + ' ' + (b.shift() || '')).trim();
|
|
return '| ' + a.concat(b).join(' | ') + ' |';
|
|
}
|
|
|
|
function trimTableCellsToExpected(line, expected) {
|
|
var cells = tableCells(line);
|
|
while (cells.length < expected) cells.push('');
|
|
return '| ' + cells.slice(0, expected).join(' | ') + ' |';
|
|
}
|
|
|
|
export function fallbackMarkdown(text) {
|
|
var html = escapeHtml(text)
|
|
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
|
|
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
|
|
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
html = html.split(/\n{2,}/).map(function (block) {
|
|
if (/^\s*<h\d/.test(block) && block.indexOf('\n') !== -1) {
|
|
var parts = block.split('\n');
|
|
return parts.shift() + fallbackMarkdown(parts.join('\n'));
|
|
}
|
|
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
|
|
var lines = block.split('\n');
|
|
if (isMarkdownTable(lines)) return renderFallbackTable(lines);
|
|
if (lines.some(function (l) { return /^\s*[-*] /.test(l); })) return renderMixedList(lines);
|
|
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
|
|
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
|
|
}
|
|
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
|
|
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
|
|
}
|
|
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
|
|
}).join('');
|
|
return html;
|
|
}
|
|
|
|
function renderMixedList(lines) {
|
|
var html = '';
|
|
var list = [];
|
|
var paragraph = [];
|
|
lines.forEach(function(line) {
|
|
if (/^\s*[-*] /.test(line)) {
|
|
if (paragraph.length) {
|
|
html += '<p>' + paragraph.join('<br>') + '</p>';
|
|
paragraph = [];
|
|
}
|
|
list.push(line.replace(/^\s*[-*] /, ''));
|
|
return;
|
|
}
|
|
if (list.length) {
|
|
html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
|
|
list = [];
|
|
}
|
|
if (line.trim()) paragraph.push(line);
|
|
});
|
|
if (paragraph.length) html += '<p>' + paragraph.join('<br>') + '</p>';
|
|
if (list.length) html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
|
|
return html;
|
|
}
|
|
|
|
function isMarkdownTable(lines) {
|
|
return lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[1]);
|
|
}
|
|
|
|
function renderFallbackTable(lines) {
|
|
var header = tableCells(lines[0]);
|
|
var rows = lines.slice(2).filter(function(line) { return /^\s*\|.*\|\s*$/.test(line); }).map(tableCells);
|
|
return '<table><thead><tr>' + header.map(function(cell) { return '<th>' + cell + '</th>'; }).join('') + '</tr></thead><tbody>' +
|
|
rows.map(function(row) { return '<tr>' + row.map(function(cell) { return '<td>' + cell + '</td>'; }).join('') + '</tr>'; }).join('') +
|
|
'</tbody></table>';
|
|
}
|
|
|
|
function tableCells(line) {
|
|
return String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function(cell) { return cell.trim(); });
|
|
}
|
|
|
|
export function renderLatexText(text, katex) {
|
|
if (!katex) return text;
|
|
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); });
|
|
}
|
|
|
|
function safeKatex(katex, expr, displayMode) {
|
|
try { return katex.renderToString(expr, { displayMode: displayMode, throwOnError: false }); }
|
|
catch (e) { return escapeHtml(expr); }
|
|
}
|
|
|
|
export function escapeHtml(s) {
|
|
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
export function escapeAttr(s) {
|
|
return escapeHtml(s).replace(/'/g, ''');
|
|
}
|