diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js
index 7b285c1..20a8556 100644
--- a/public/js/assistant/citations.js
+++ b/public/js/assistant/citations.js
@@ -1,3 +1,6 @@
+// Recognize HTML syntax (including quoted >), not clinical comparisons such as <1 month.
+var HTML_TAG_PATTERN = /|<\/[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);
diff --git a/public/js/assistant/export.js b/public/js/assistant/export.js
index b087c9d..83ec24b 100644
--- a/public/js/assistant/export.js
+++ b/public/js/assistant/export.js
@@ -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) {}
diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js
index bf9ed8d..566d9f2 100644
--- a/test/assistant-saved-tables.test.js
+++ b/test/assistant-saved-tables.test.js
@@ -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, 'Alpha');
+ 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||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');
+ }
+ }
+});