fix: preserve export navigation and clinical source boundaries

This commit is contained in:
Daniel 2026-09-07 07:10:47 +02:00
parent b0bebe6970
commit dc83f6e21b
3 changed files with 91 additions and 3 deletions

View file

@ -1,3 +1,6 @@
// Recognize HTML syntax (including quoted >), not clinical comparisons such as <1 month.
var HTML_TAG_PATTERN = /<!--[\s\S]*?-->|<\/[A-Za-z][A-Za-z0-9-]*\s*>|<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`\x00-\x20]+))?)*\s*\/?>/;
export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var text = String(md || '');
@ -16,7 +19,9 @@ export function renderAssistantMarkdown(md, sources, options) {
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(new RegExp(
/!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|<(?:https?:\/\/|mailto:)[^\s<>]+>|<[^\s<>@]+@[^\s<>@]+>/.source + '|' + HTML_TAG_PATTERN.source,
'gi'), links.hold);
text = text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]');
text = renderLatexText(text, opts.katex, embedded.hold);
var limited = false;
@ -56,7 +61,9 @@ function wrapTables(html) {
export function renderCitationLinks(html, sources, options) {
var opts = options || {};
return String(html || '').replace(/<(pre|code|a)\b[^>]*>[\s\S]*?<\/\1>|<[^>]*>|\[((?:\d+\s*,\s*)*\d+)\]/gi, function (match, tag, cluster) {
return String(html || '').replace(new RegExp(
/<(pre|code|a)\b[^>]*>[\s\S]*?<\/\1>/.source + '|' + HTML_TAG_PATTERN.source + '|' + /\[((?:\d+\s*,\s*)*\d+)\]/.source,
'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;
@ -176,7 +183,7 @@ export function normalizeMarkdownText(text, options) {
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.replace(/(`+)[\s\S]*?\1|\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^$\n]+\$|!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|\\\|/gi, protectedText.hold);
text = text.split('\n').map(function(line) {
if (!/\|[ \t]*:?-{2,}:?[ \t]*\|/.test(line)) return line;
var recovered = recoverLegacyTableLine(line);

View file

@ -88,6 +88,18 @@ export function createAssistantExporter(options) {
});
if (closeBtn) closeBtn.addEventListener('click', closeInlineExport);
modal.addEventListener('click', function (event) {
if (!event.defaultPrevented && !event.button && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) {
var citation = event.target.closest('.assistant-cite');
var href = citation && citation.getAttribute('href');
var reference = /^#ref-\d+-\d+$/.test(href || '') &&
modal.querySelector(':scope > .assistant-export-sheet > .export-section > .refs > ' + href);
if (reference) {
event.preventDefault(); // Fragment navigation also fires the Back/popstate close handler.
reference.tabIndex = -1;
reference.focus({ preventScroll: true });
reference.scrollIntoView({ block: 'nearest' });
}
}
if (event.target === modal) closeInlineExport();
});
try { window.history.pushState({ assistantExport: true }, '', window.location.href); } catch (e) {}

View file

@ -207,6 +207,9 @@ test('math/code literals and sentinel-shaped input survive postprocessing withou
reopen(app, '[URL](https://example.test/$notmath$)');
assert.equal(bubble(app).querySelector('a').getAttribute('href'), 'https://example.test/$notmath$');
assert.ok(!expressions.includes('notmath'));
reopen(app, '<span title="threshold > 1 [2][1] $notmath$">Alpha</span>');
assert.equal(bubble(app).querySelector('span[title]').title, 'threshold > 1 [2][1] $notmath$');
assert.ok(!expressions.includes('notmath'), 'quoted HTML attributes are literal, including >, citations and math');
});
test('legacy loss provenance and retained raw extension survive v2 re-save and follow-up with strict guards', async t => {
@ -285,3 +288,69 @@ test('clicking reused citation numbers selects the original turn source without
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]');
});
test('inline export reference activation preserves the preview while browser Back still closes it', t => {
const app = ui(t);
reopen(app, table);
app.context.exportAnswerPdf();
const modal = app.document.querySelector('#assistant-export-modal');
const link = modal.querySelector('.assistant-cite[href="#ref-1-2"]');
const reference = modal.querySelector('#ref-1-2');
let scrolled = 0;
reference.scrollIntoView = () => { scrolled++; };
const decoy = app.document.createElement('span');
decoy.id = reference.id;
decoy.scrollIntoView = () => { throw new Error('Answer markup must not replace the reference target'); };
modal.querySelector('.answer').prepend(decoy);
const originalHash = app.window.location.hash;
const click = new app.window.MouseEvent('click', { bubbles: true, cancelable: true });
link.dispatchEvent(click);
assert.equal(click.defaultPrevented, true, 'fragment navigation must not trigger the popstate close handler');
assert.equal(scrolled, 1);
assert.equal(app.document.activeElement, reference);
assert.equal(app.window.location.hash, originalHash);
assert.equal(modal.isConnected, true);
// Observe the application's decision, then stop jsdom's native navigation.
let preventedByApp;
modal.addEventListener('click', event => {
preventedByApp = event.defaultPrevented;
event.preventDefault();
});
for (const modifiers of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
link.dispatchEvent(new app.window.MouseEvent('click', { bubbles: true, cancelable: true, ...modifiers }));
assert.equal(preventedByApp, false, 'modified clicks retain native semantics');
assert.equal(scrolled, 1);
}
app.window.dispatchEvent(new app.window.PopStateEvent('popstate'));
assert.equal(app.document.querySelector('#assistant-export-modal'), null);
});
test('comparison angles and compact URLs preserve every source-column link, export reference and raw saved turn', async t => {
const fixtures = [
'| Age | Dose | Sources |\n| --- | --- | --- |\n| <1 month | 2 | 1 |\n| >1 month | 3 | 2 |',
'| Item | Sources |\n|---|---|\n|https://example.test/reference|1|\n|Beta|2|',
'| Item | Sources |\n|---|---|\n|https://example.test/a\\|b|1|\n|Beta|2|',
'| Item | Sources |\n|---|---|\n|<https://example.test/reference>|1|\n|Beta|2|'
];
for (const parser of [marked, null]) {
const app = ui(t, parser);
if (!parser) app.window.markdownit = MarkdownIt;
for (const content of fixtures) {
reopen(app, content);
assert.equal(rows(bubble(app)).length, 2);
assert.deepEqual([...bubble(app).querySelectorAll('.assistant-cite')].map(a => a.dataset.sourceNumber), ['1', '2']);
await app.context.saveCurrentChat();
assert.equal(app.saves.at(-1).messages[1].content, content);
app.context.exportAnswerPdf();
const modal = app.document.querySelector('#assistant-export-modal');
assert.deepEqual([...modal.querySelectorAll('.assistant-cite')].map(a => a.dataset.sourceNumber), ['1', '2']);
for (const [number, page] of [[1, 7], [2, 19]]) {
const link = modal.querySelector('.assistant-cite[data-source-number="' + number + '"]');
assert.match(modal.querySelector(link.getAttribute('href')).textContent, new RegExp('page ' + page));
}
if (content.includes('<1 month')) assert.deepEqual(rows(modal).map(row => row[0]), ['<1 month', '>1 month']);
if (content.includes('a\\|b')) assert.equal(decodeURIComponent(modal.querySelector('a[href^="https:"]').getAttribute('href')), 'https://example.test/a|b');
}
}
});