fix: preserve export navigation and clinical source boundaries
This commit is contained in:
parent
b0bebe6970
commit
dc83f6e21b
3 changed files with 91 additions and 3 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue