The old renderer rewrote the text: it found "[n]" with regexes, renumbered them, and swapped the result back in — which broke inside `arr[2][1]`, inside HTML attributes, and whenever two turns disagreed about what "[3]" meant. It also had a fallback markdown renderer of its own for when the rewrite produced something markdown-it would not parse. Now "[n]" is an inline rule registered on the same markdown-it instance that renders everything else. The parser decides what is prose and what is code, a link, or a URL, so the rule never sees "[1]" inside a code span, and it steps aside for "[1](url)". Math is two more rules on the same parser instead of a regex pre-pass, so "$" inside a URL is no longer math. Identity vs display: the stored "[n]" and each card's id are the source's identity (sourceNumber) and are never rewritten. The number a reader sees is the order of first appearance, computed at render time from the token stream (orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat re-opens pointing at the same cards it was saved with. Stored messages and sources are untouched; export and the modal resolve by identity. Translated HTML gets the same links through a TreeWalker over text nodes (linkCitationsInHtml) rather than a regex over markup. Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable), renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert token output; harnesses that render for real are given a parser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
113 lines
5.9 KiB
JavaScript
113 lines
5.9 KiB
JavaScript
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 read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
|
|
function ui(t) {
|
|
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
|
|
const window = dom.window;
|
|
window.eval(read('public/js/accountBoundary.js'));
|
|
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
|
window.marked = marked;
|
|
window.markdownit = require('markdown-it');
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const toasts = [];
|
|
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
|
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
saveAssistantChat: async () => ({ success: true }) };
|
|
vm.createContext(context);
|
|
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
|
|
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
|
|
}
|
|
appBinding(t, context);
|
|
t.after(() => window.close());
|
|
return { context, document: window.document, window, toasts };
|
|
}
|
|
|
|
// The production page binds delegated clicks in bindEvents; the vm harness mirrors it.
|
|
function appBinding(t, context) {
|
|
context.bindEvents();
|
|
}
|
|
|
|
const longExcerpt = 'Synthetic excerpt. ' + 'x'.repeat(15000);
|
|
|
|
function openModal(app, n = 1) {
|
|
const cite = app.document.querySelector('.assistant-cite[data-source-number="' + n + '"]');
|
|
assert.ok(cite, 'citation chip exists');
|
|
cite.click();
|
|
return app.document.querySelector('.assistant-source-modal');
|
|
}
|
|
|
|
test('citation chips open an OWUI-style source modal with title, page and 10k excerpt preview', t => {
|
|
const app = ui(t);
|
|
const sources = [{ number: 1, title: 'Nelson Textbook of Pediatrics', page: 7, excerpt: longExcerpt }];
|
|
app.context.restoreSavedChat({ version: 2, messages: [
|
|
{ role: 'user', content: 'Question' },
|
|
{ role: 'assistant', content: 'Answer [1].', sources }
|
|
], lastAnswer: 'Answer [1].', sources });
|
|
const modal = openModal(app);
|
|
assert.ok(modal, 'modal opens');
|
|
assert.equal(modal.getAttribute('role'), 'dialog');
|
|
assert.match(modal.textContent, /Nelson Textbook of Pediatrics/);
|
|
assert.match(modal.textContent, /page 7/);
|
|
const excerpt = modal.querySelector('[data-assistant-source-excerpt]');
|
|
assert.ok(excerpt);
|
|
assert.ok(excerpt.textContent.length >= 10000 && excerpt.textContent.length <= 10200, '10k preview shown');
|
|
const showAll = modal.querySelector('[data-assistant-source-show-all]');
|
|
assert.ok(showAll, 'Show all offered for long excerpts');
|
|
showAll.click();
|
|
assert.ok(excerpt.textContent.length >= 15000, 'Show all reveals the full excerpt');
|
|
assert.equal(showAll.hidden, true, 'toggle hides once expanded');
|
|
// Sidebar switching is preserved alongside the modal.
|
|
assert.match(app.document.querySelector('#assistant-source-1').textContent, /Nelson Textbook of Pediatrics.*page 7/is);
|
|
});
|
|
|
|
test('source modal closes via button, backdrop and Escape', t => {
|
|
const app = ui(t);
|
|
const sources = [{ number: 1, title: 'Synthetic source', page: 3, excerpt: 'Short excerpt.' }];
|
|
app.context.restoreSavedChat({ version: 2, messages: [
|
|
{ role: 'user', content: 'Question' },
|
|
{ role: 'assistant', content: 'Answer [1].', sources }
|
|
], lastAnswer: 'Answer [1].', sources });
|
|
let modal = openModal(app);
|
|
assert.equal(modal.querySelector('[data-assistant-source-show-all]'), null, 'no Show all for short excerpts');
|
|
modal.querySelector('.modal-close').click();
|
|
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
|
|
modal = openModal(app);
|
|
modal.click(); // backdrop is the modal element itself
|
|
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
|
|
modal = openModal(app);
|
|
app.window.document.dispatchEvent(new app.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
assert.equal(app.document.querySelector('.assistant-source-modal'), null);
|
|
});
|
|
|
|
test('per-turn source maps select the correct source for reused citation numbers', 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 });
|
|
const citations = app.document.querySelectorAll('#assistant-messages .assistant-cite');
|
|
assert.equal(citations.length, 2);
|
|
citations[0].click();
|
|
assert.match(app.document.querySelector('.assistant-source-modal').textContent, /First turn reference/);
|
|
assert.match(app.document.querySelector('.assistant-source-modal').textContent, /page 11/);
|
|
app.document.querySelector('.assistant-source-modal .modal-close').click();
|
|
citations[1].click();
|
|
const modal = app.document.querySelector('.assistant-source-modal');
|
|
assert.match(modal.textContent, /Second turn reference/);
|
|
assert.match(modal.textContent, /page 23/);
|
|
assert.doesNotMatch(modal.textContent, /First turn reference/);
|
|
});
|