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
90 lines
5 KiB
JavaScript
90 lines
5 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);
|
|
const style = window.document.createElement('style');
|
|
style.textContent = read('public/css/assistant.css');
|
|
window.document.head.appendChild(style);
|
|
window.marked = marked;
|
|
window.markdownit = require('markdown-it');
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
|
setTimeout() {}, clearTimeout() {}, showToast() {}, 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);
|
|
}
|
|
t.after(() => window.close());
|
|
return { context, document: window.document, window };
|
|
}
|
|
|
|
function userBubble(app) {
|
|
return app.document.querySelector('.assistant-msg.user .assistant-bubble');
|
|
}
|
|
|
|
test('user messages render markdown while the raw transcript stays canonical', t => {
|
|
const app = ui(t);
|
|
const md = '**Bold** plan\n\n- item one\n- item two\n\n`inline code` and 2+2=4';
|
|
app.context.appendMessage('user', md);
|
|
const bubble = userBubble(app);
|
|
assert.match(bubble.innerHTML, /<strong>Bold<\/strong>/);
|
|
assert.match(bubble.innerHTML, /<li>item one<\/li>/);
|
|
assert.match(bubble.innerHTML, /<li>item two<\/li>/);
|
|
assert.match(bubble.innerHTML, /<code>inline code<\/code>/);
|
|
assert.equal(app.context.messages[0].content, md, 'raw user text is stored unchanged');
|
|
assert.equal(app.context.messages[0].role, 'user');
|
|
assert.deepEqual(JSON.parse(JSON.stringify(app.context.messages[0].sources)), [], 'user turns carry no source map');
|
|
assert.equal(bubble.querySelector('.assistant-cite'), null, 'user text cannot create citation links');
|
|
});
|
|
|
|
test('user tables render with the OWUI wrapper and keyboard-scroll accessibility attributes', t => {
|
|
const app = ui(t);
|
|
app.context.appendMessage('user', '| Item | Dose |\n| :--- | ---: |\n| Alpha | 2 |');
|
|
const scroll = userBubble(app).querySelector('.assistant-table-scroll');
|
|
assert.ok(scroll, 'user table gets the shared wrapper');
|
|
assert.equal(scroll.getAttribute('tabindex'), '0');
|
|
assert.equal(scroll.getAttribute('role'), 'region');
|
|
assert.equal(scroll.getAttribute('aria-label'), 'Scrollable table');
|
|
assert.equal(scroll.querySelectorAll('tbody tr').length, 1);
|
|
});
|
|
|
|
test('inline markdown images respect the safe allowlist: own assets and data URIs only', t => {
|
|
const app = ui(t);
|
|
const id = '12345678-1234-1234-1234-123456789abc';
|
|
app.context.appendMessage('user', ' and ');
|
|
const imgs = userBubble(app).querySelectorAll('img');
|
|
assert.equal(imgs.length, 2);
|
|
assert.ok(imgs[0].getAttribute('src').endsWith('/api/generated-images/' + id));
|
|
assert.ok(imgs[1].getAttribute('src').startsWith('data:image/png;base64,'));
|
|
app.context.appendMessage('user', ' and a normal [link](https://example.test/page)');
|
|
const bubbles = app.document.querySelectorAll('.assistant-msg.user .assistant-bubble');
|
|
assert.equal(bubbles[1].querySelectorAll('img').length, 0, 'external image URLs never render as images');
|
|
assert.match(bubbles[1].textContent, /evil/);
|
|
assert.equal(bubbles[1].querySelector('a[href="https://example.test/page"]') !== null, true, 'ordinary links stay links');
|
|
});
|
|
|
|
test('assistant markdown rendering and citation chips are unchanged by user-markdown mode', t => {
|
|
const app = ui(t);
|
|
const sources = [{ title: 'Synthetic A', page: 7 }];
|
|
app.context.appendMessage('user', 'What is the dose [1]?');
|
|
app.context.appendMessage('assistant', 'Dose [1].', sources);
|
|
const assistantBubble = app.document.querySelector('.assistant-msg.assistant .assistant-bubble');
|
|
assert.ok(assistantBubble.querySelector('.assistant-cite[data-source-number="1"]'));
|
|
assert.equal(userBubble(app).querySelector('.assistant-cite'), null);
|
|
assert.deepEqual(app.context.messages[1].sources, sources, 'per-turn source maps preserved');
|
|
});
|