Borrowed from the quiz app's AI Mode, where validating citations and ordering them fall out of the same pass: it collects the sources an answer actually used into an insertion-ordered map, so the list comes back in first-citation order for free. Ours listed sources in retrieval order — an order the reader never sees and has no way to follow. An answer whose first citation was [7] opened a list that began at [1], so matching a marker to a source meant hunting. Reference lists in published writing are numbered by first appearance for exactly this reason. Cited sources now come first, renumbered by first appearance, and the markers in the text are rewritten to match. Anything retrieved and not cited keeps its place after them, labelled "not cited" — the panel is also a view of what the search returned, which is worth keeping, but it should not sit among the numbers the answer used. The marker itself now shows its number instead of the word "src". Every citation read identically, so the only way to tell one from another was to hover it — which made the numbered list beneath useless to match against. The export has shown numbers since the day "src" was introduced, with no recorded reason for the difference. Renumbering happens once the whole answer is known, never while streaming: the order is the order of first citation, so a citation that has not arrived yet cannot take its place, and numbers would shuffle under the reader mid-sentence. The text is rewritten in a single pass — number by number would turn 2 into 1 and then that 1 into whatever 1 maps to. An invented citation reserves no position and is left exactly as it was. It is still not turned into a link, and citation_audit still records it; what matters here is that it cannot push a real source down the list. Accuracy was already held: a marker with no matching source never becomes a link. This changes what a reader can do with the ones that are real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
475 lines
29 KiB
JavaScript
475 lines
29 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 MarkdownIt = require('markdown-it');
|
|
const { savedChatPayload } = require('../src/utils/clinicalConversation');
|
|
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
|
const sources = [{ title: 'Synthetic A', page: 7 }, { title: 'Synthetic B', page_number: 19 }];
|
|
const table = '| Item | Value (mg/kg) | Notes | Sources |\n| :--- | ---: | :---: | --- |\n| Alpha | 1.25 | A - B | 2 |\n| Beta | 2-4 | unchanged | [1] |';
|
|
const collapse = text => text.replace(/\s+/g, ' ').trim();
|
|
|
|
function ui(t, parser = marked) {
|
|
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 = parser;
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const saves = [];
|
|
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
|
setTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(savedChatPayload(body)))); return { 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, saves, window };
|
|
}
|
|
function rows(element) { return [...element.querySelectorAll('tbody tr')].map(row => [...row.cells].map(cell => cell.textContent.trim())); }
|
|
function bubble(app) { return app.document.querySelector('.assistant-msg.assistant .assistant-bubble'); }
|
|
function reopen(app, content, version = 2, lastAnswer = content) {
|
|
app.context.restoreSavedChat({ version, messages: [{ role: 'user', content: 'Synthetic question' }, { role: 'assistant', content, sources }], sources, lastAnswer });
|
|
}
|
|
|
|
test('raw v2 actual save/load and SSE/fallback/append/export share all table cells and source pages', async t => {
|
|
const app = ui(t);
|
|
const c = app.context;
|
|
for (const fallback of [false, true]) {
|
|
c.clearConversation();
|
|
c.openAssistantStream = async () => new Response(fallback ? '' : 'event: done\ndata: ' + JSON.stringify({ answer: table, sources }) + '\n\n');
|
|
c.fetchAssistantChat = async () => ({ success: true, answer: table, sources });
|
|
const loading = c.appendLoadingMessage();
|
|
await c.streamAssistantResponse({}, loading);
|
|
// The chip shows its source number now rather than the word "src", so a
|
|
// reader can match a marker to the numbered list under the answer.
|
|
assert.deepEqual(rows(bubble(app)), [['Alpha', '1.25', 'A - B', '2'], ['Beta', '2-4', 'unchanged', '1']]);
|
|
assert.match(bubble(app).querySelector('.assistant-cite').title, /Source 2: Synthetic B, page 19/);
|
|
assert.equal(bubble(app).querySelectorAll('td')[1].getAttribute('align'), 'right');
|
|
assert.equal(app.window.getComputedStyle(bubble(app).querySelectorAll('td')[1]).textAlign, 'right');
|
|
assert.equal(bubble(app).querySelector('.assistant-table-scroll').getAttribute('tabindex'), '0');
|
|
await c.performAutosave();
|
|
assert.equal(app.saves.at(-1).version, 2);
|
|
assert.equal(app.saves.at(-1).messages[0].content, table);
|
|
assert.equal(app.saves.at(-1).lastAnswer, table);
|
|
c.restoreSavedChat(app.saves.at(-1));
|
|
assert.equal(rows(bubble(app)).length, 2);
|
|
c.exportAnswerPdf();
|
|
const exported = app.document.querySelector('#assistant-export-modal');
|
|
assert.deepEqual(rows(exported), [['Alpha', '1.25', 'A - B', '2'], ['Beta', '2-4', 'unchanged', '1']]);
|
|
for (const cite of exported.querySelectorAll('.assistant-cite')) {
|
|
assert.ok(exported.querySelector(cite.getAttribute('href')), 'export citation resolves to its own reference');
|
|
}
|
|
c.clearConversation();
|
|
c.appendMessage('assistant', table, sources);
|
|
assert.equal(rows(bubble(app)).length, 2);
|
|
}
|
|
});
|
|
|
|
test('legacy collapsed complete tables recover for display only, including multiple tables', async t => {
|
|
const app = ui(t);
|
|
const raw = collapse(table + '\n\n' + table.replace(/Alpha/g, 'Gamma').replace(/Beta/g, 'Delta'));
|
|
reopen(app, raw, 1);
|
|
assert.equal(bubble(app).querySelectorAll('table').length, 2);
|
|
assert.deepEqual(rows(bubble(app)).map(row => row[0]), ['Alpha', 'Beta', 'Gamma', 'Delta']);
|
|
assert.equal(app.context.messages[1].content, raw);
|
|
app.context.performAutosave();
|
|
assert.equal(app.saves[0].messages[1].content, raw);
|
|
app.context.exportAnswerPdf();
|
|
assert.equal(app.document.querySelectorAll('#assistant-export-modal table').length, 2);
|
|
});
|
|
|
|
test('long new tables survive serialization and both installed parsers without losing tail, caption, notes or units', async t => {
|
|
for (const parser of [marked, null]) {
|
|
const app = ui(t, parser);
|
|
if (!parser) app.window.markdownit = MarkdownIt;
|
|
const long = 'Table 1. Synthetic dose comparison\n\n' + table + '\n' + Array.from({ length: 700 }, (_, n) => '| Row ' + n + ' | 0.25 | A - B, 5-10 mg/kg | [2] |').join('\n') + '\n\nNote: No real clinical data.\n\n† Synthetic footnote.';
|
|
const saved = JSON.parse(JSON.stringify(savedChatPayload({ messages: [{ role: 'assistant', content: long, sources }], lastAnswer: long, sources })));
|
|
app.context.restoreSavedChat(saved);
|
|
assert.equal(rows(bubble(app)).length, 702);
|
|
assert.equal(rows(bubble(app)).at(-1)[0], 'Row 699');
|
|
assert.match(bubble(app).textContent, /Table 1\. Synthetic dose comparison/);
|
|
assert.match(bubble(app).textContent, /† Synthetic footnote\./);
|
|
assert.equal(saved.messages[0].content, long);
|
|
}
|
|
});
|
|
|
|
test('code, math, escaped pipes and URLs cannot become lists or steal source-column cells', t => {
|
|
const app = ui(t);
|
|
const special = '| Item | Notes | Sources |\n| --- | --- | --- |\n| Alpha | `A - B [1]` and $x - y$ and \\(a - b\\) | 2 |\n| Beta | a\\|b and [URL](https://example.test/a-b?q=1%7C2) | [1] |';
|
|
reopen(app, special);
|
|
assert.equal(rows(bubble(app)).length, 2);
|
|
assert.equal(rows(bubble(app))[0][1], 'A - B [1] and $x - y$ and \\(a - b\\)');
|
|
assert.equal(rows(bubble(app))[1][1], 'a|b and URL');
|
|
assert.equal(rows(bubble(app))[1][2], '1');
|
|
assert.equal(bubble(app).querySelector('code .assistant-cite'), null);
|
|
assert.equal(bubble(app).querySelector('a[href^="https:"]').getAttribute('href'), 'https://example.test/a-b?q=1%7C2');
|
|
for (const literal of ['`' + collapse(table) + '`', '~~~md\n' + collapse(table) + '\n~~~', ' ' + collapse(table), '$$' + collapse(table) + '$$', '\\[' + collapse(table) + '\\]', 'https://example.test/' + collapse(table).replace(/ /g, '%20')]) {
|
|
reopen(app, literal, 1);
|
|
assert.equal(bubble(app).querySelectorAll('table').length, 0, literal);
|
|
assert.equal(bubble(app).querySelectorAll('li').length, 0, literal);
|
|
}
|
|
});
|
|
|
|
test('ambiguous, empty-cell and truncated legacy tables are unchanged and honestly limited', t => {
|
|
const app = ui(t);
|
|
for (const raw of [collapse(table).slice(0, -3), '| A | B | | --- | --- | | x | |', '| A | B | | --- | --- | | x | y | extra |']) {
|
|
reopen(app, raw, 1);
|
|
assert.equal(bubble(app).querySelectorAll('table').length, 0);
|
|
assert.ok(bubble(app).textContent.includes(raw));
|
|
assert.match(bubble(app).textContent, /could not.*recover|cannot.*recover/i);
|
|
assert.equal(app.context.messages[1].content, raw);
|
|
}
|
|
const withLiterals = '| A | B | | --- | --- | | `x - y` and $a - b$ | [URL](https://example.test/) | incomplete';
|
|
reopen(app, withLiterals, 1);
|
|
assert.ok(bubble(app).textContent.includes(withLiterals));
|
|
});
|
|
|
|
test('legacy exact-limit prefix may display only retained lastAnswer, never manufacture missing content', async t => {
|
|
const app = ui(t);
|
|
const full = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | complete | [1] |').join('\n'));
|
|
assert.ok(full.length > 12000 && full.length < 30000);
|
|
const clipped = full.slice(0, 12000);
|
|
reopen(app, clipped, 1, full);
|
|
assert.equal(rows(bubble(app)).length, 452);
|
|
assert.match(bubble(app).textContent, /retained.*lastAnswer/i);
|
|
assert.equal(app.context.messages[1].content, clipped);
|
|
app.context.performAutosave();
|
|
assert.equal(app.saves[0].messages[1].content, clipped);
|
|
assert.equal(app.saves[0].lastAnswer, full);
|
|
app.context.exportAnswerPdf();
|
|
assert.equal(app.document.querySelectorAll('#assistant-export-modal tbody tr').length, 452);
|
|
for (const retained of [clipped, 'different ' + full]) {
|
|
reopen(app, clipped, 1, retained);
|
|
assert.match(bubble(app).textContent, /clipped|truncat/i);
|
|
assert.doesNotMatch(bubble(app).textContent, /Row 449/);
|
|
}
|
|
reopen(app, clipped, 2, full);
|
|
assert.doesNotMatch(bubble(app).textContent, /Row 449/, 'v2 does not infer truncation from an ordinary 12,000-character message');
|
|
});
|
|
|
|
test('all render entrypoints fail closed on missing sanitizer, malicious HTML or parser errors', t => {
|
|
const app = ui(t);
|
|
const malicious = table + '\n\n<img src=x onerror="alert(1)"><script>alert(2)</script><a href="javascript:alert(3)">bad</a>';
|
|
for (const mode of ['normal', 'no-sanitizer', 'throw']) {
|
|
if (mode === 'no-sanitizer') delete app.window.DOMPurify;
|
|
if (mode === 'throw') app.window.marked = { lexer: marked.lexer, parse() { throw new Error('Synthetic parser failure'); } };
|
|
reopen(app, malicious);
|
|
app.context.exportAnswerPdf();
|
|
assert.equal(app.document.querySelector('script, [onerror], a[href^="javascript:"]'), null);
|
|
assert.ok(bubble(app).textContent.includes('Alpha'));
|
|
assert.ok(app.document.querySelector('#assistant-export-modal').textContent.includes('Beta'));
|
|
}
|
|
});
|
|
|
|
test('legacy recovery refuses missing boundaries; supports independent lines and literal syntax inside complete rows', t => {
|
|
const app = ui(t);
|
|
const escaped = '| Name | Notes | Source |\n| :--- | ---: | --- |\n| A | a\\|b and `x\\|y` and $x - y$ | 2 |\n| B | https://example.test/a?b=1%7C2 | 1 |';
|
|
reopen(app, collapse(escaped), 1);
|
|
assert.equal(rows(bubble(app)).length, 2);
|
|
assert.equal(rows(bubble(app))[0][1], 'a|b and x|y and $x - y$');
|
|
assert.equal(rows(bubble(app))[0][2], '2');
|
|
reopen(app, collapse(table) + '\n\nCaption two\n\n' + collapse(table), 1);
|
|
assert.equal(bubble(app).querySelectorAll('table').length, 2);
|
|
for (const raw of ['| A | B | --- | --- | x | y |', '| A | B | | --- | --- | | x | y', '| A | B | | --- | --- | | x | y | trailing prose']) {
|
|
reopen(app, raw, 1);
|
|
assert.equal(bubble(app).querySelectorAll('table').length, 0);
|
|
assert.ok(bubble(app).textContent.includes(raw));
|
|
assert.match(bubble(app).textContent, /could not safely recover/i);
|
|
}
|
|
});
|
|
|
|
test('math/code literals and sentinel-shaped input survive postprocessing without citation or HTML interpretation', t => {
|
|
const app = ui(t);
|
|
const expressions = [];
|
|
app.window.katex = { renderToString(expression) { expressions.push(expression); return '<span class="katex">' + expression.replace(/&/g, '&').replace(/</g, '<') + '</span>'; } };
|
|
const raw = '| Item | Value | Sources |\n| --- | --- | --- |\n| Alpha | `a\\|b [2][1] $notmath$` and $x \\mid y [1]$ | 2 |\n| Beta | \\(a - b\\) | 1 |';
|
|
reopen(app, raw);
|
|
assert.equal(rows(bubble(app)).length, 2);
|
|
assert.equal(bubble(app).querySelector('code').textContent, 'a|b [2][1] $notmath$');
|
|
assert.equal(bubble(app).querySelector('code .katex, .katex .assistant-cite'), null);
|
|
assert.deepEqual(expressions, ['x \\mid y [1]', 'a - b']);
|
|
assert.equal(rows(bubble(app))[1][2], '1');
|
|
for (const raw of ['$$\n' + table + '\n$$', '~~~md\n' + table + '\n[1][2] $code$\n~~~', '\uE000html:0\uE001 and `\uE000markdown:0\uE001 [2][1]`']) {
|
|
reopen(app, raw);
|
|
assert.equal(bubble(app).querySelectorAll('table, .assistant-cite').length, 0);
|
|
}
|
|
reopen(app, '[URL](https://example.test/[1][2]) and A - B and 5-10 mg/kg');
|
|
assert.equal(decodeURIComponent(bubble(app).querySelector('a').getAttribute('href')), 'https://example.test/[1][2]');
|
|
assert.match(bubble(app).textContent, /A - B and 5-10 mg\/kg/);
|
|
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 => {
|
|
const app = ui(t);
|
|
const full = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | retained | [1] |').join('\n'));
|
|
const clipped = full.slice(0, 12000);
|
|
reopen(app, clipped, 1, full);
|
|
app.context.appendMessage('user', 'Follow-up');
|
|
app.context.appendMessage('assistant', 'New answer', sources);
|
|
app.context.lastAnswer = 'New answer';
|
|
app.context.performAutosave();
|
|
const saved = app.saves[0];
|
|
assert.equal(saved.version, 2);
|
|
assert.equal(saved.messages[1].content, clipped);
|
|
assert.equal(saved.messages[1].retainedAnswer, full);
|
|
assert.equal(saved.messages[1].legacyClipped, true);
|
|
app.context.restoreSavedChat(saved);
|
|
assert.equal(rows(bubble(app)).length, 452);
|
|
assert.equal(app.context.messages[1].content, clipped);
|
|
assert.equal(app.context.conversationSize(''), saved.messages.reduce((n, m) => n + m.content.length, 0), 'display-only extension is not silently sent as inference history');
|
|
for (const extension of [clipped, 'not a prefix ' + full, full + 'x'.repeat(30000), full + '\n', undefined]) {
|
|
const result = savedChatPayload({ messages: [{ role: 'assistant', content: clipped, legacyClipped: true, retainedAnswer: extension }] });
|
|
assert.equal(result.messages[0].retainedAnswer, undefined);
|
|
assert.equal(result.messages[0].content, clipped);
|
|
}
|
|
const differentSources = { version: 1, messages: [{ role: 'assistant', content: clipped, sources: [{ title: 'Other', page: 99 }] }], sources, lastAnswer: full };
|
|
app.context.restoreSavedChat(differentSources);
|
|
assert.doesNotMatch(bubble(app).textContent, /Row 449/);
|
|
reopen(app, clipped, 1, clipped + 'x'.repeat(30000 - clipped.length));
|
|
assert.match(bubble(app).textContent, /30,000 characters.*absent content cannot be recovered/);
|
|
});
|
|
|
|
test('parser-unavailable literals stay inert, and embedded chart initialization retains an attached bubble', t => {
|
|
const app = ui(t, null);
|
|
for (const raw of [' ' + collapse(table), '~~~md\n' + table + '\n~~~', '$$' + table + '$$']) {
|
|
reopen(app, raw, 1);
|
|
assert.equal(bubble(app).querySelectorAll('table, .assistant-cite').length, 0);
|
|
assert.match(bubble(app).textContent, /Alpha/);
|
|
}
|
|
let rendered = 0;
|
|
app.window.HTMLCanvasElement.prototype.getContext = function() {
|
|
assert.equal(this.isConnected, true);
|
|
return { canvas: this };
|
|
};
|
|
app.window.Chart = function(context, config) {
|
|
assert.equal(context.canvas.isConnected, true);
|
|
assert.equal(config.type, 'bar');
|
|
rendered++;
|
|
};
|
|
app.context.appendMessage('assistant', '```chart\n{"type":"bar","data":{"labels":["[1]"],"datasets":[]}}\n```');
|
|
assert.equal(rendered, 1);
|
|
});
|
|
|
|
test('clicking reused citation numbers selects the original turn source without changing saved history', async 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 });
|
|
app.document.addEventListener('click', app.context.onAssistantDocumentClick);
|
|
const citations = app.document.querySelectorAll('#assistant-messages .assistant-cite');
|
|
assert.equal(citations.length, 2);
|
|
citations[0].click();
|
|
assert.match(app.document.querySelector('#assistant-source-1').textContent, /First turn reference.*page 11/is);
|
|
assert.doesNotMatch(app.document.querySelector('#assistant-source-1').textContent, /Second turn reference/);
|
|
citations[1].click();
|
|
assert.match(app.document.querySelector('#assistant-source-1').textContent, /Second turn reference.*page 23/is);
|
|
citations[0].click();
|
|
app.context.performAutosave();
|
|
assert.deepEqual(app.saves[0].messages[1].sources, first);
|
|
assert.deepEqual(app.saves[0].messages[3].sources, second);
|
|
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']);
|
|
app.context.performAutosave();
|
|
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');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('durable jobs preserve legacy provenance, provisional/clicked turn sources and private export section navigation', async t => {
|
|
const app = ui(t); const c = app.context;
|
|
const { webcrypto, createHash } = require('node:crypto');
|
|
const id = '12345678-1234-1234-1234-123456789abc';
|
|
const asset = '/api/generated-images/' + id;
|
|
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
|
|
const dataUrl = 'data:image/png;base64,' + png.toString('base64');
|
|
const context = { includedTurns: 2, totalTurns: 8, used: 31990, limit: 32000 };
|
|
const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); };
|
|
c.crypto = webcrypto;
|
|
c.FileReader = class { readAsDataURL(blob) { blob.arrayBuffer().then(bytes => { this.result = 'data:' + blob.type + ';base64,' + Buffer.from(bytes).toString('base64'); this.onload(); }); } };
|
|
app.window.getAuthHeaders = () => ({ Authorization: 'Bearer synthetic-only' });
|
|
c.fetch = async url => url.includes('/jobs/') ? new Response(JSON.stringify({ success: true, status: 'done', imageUrl: asset, jobId: id, context })) :
|
|
new Response(png, { headers: { 'content-type': 'image/png', 'content-length': String(png.length), 'x-image-owner': 'synthetic-rendering-owner', 'x-image-sha256': createHash('sha256').update(png).digest('hex') } });
|
|
vm.runInContext(read('public/js/assistant/images.js').replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), c);
|
|
c.imageStore = c.createAssistantImageStore();
|
|
const retained = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | retained | [1] |').join('\n'));
|
|
const raw = retained.slice(0, 12000);
|
|
c.restoreSavedChat({ version: 1, messages: [{ role: 'assistant', content: raw, sources, imageJobs: [{ jobId: id }] }], lastAnswer: retained, sources });
|
|
const firstTable = bubble(app).querySelector('table'); const firstHtml = firstTable.outerHTML;
|
|
await tick();
|
|
assert.equal(bubble(app).querySelector('table'), firstTable); assert.equal(firstTable.outerHTML, firstHtml);
|
|
assert.doesNotMatch(bubble(app).textContent, /preceding turns included/);
|
|
assert.equal(c.messages[0].retainedAnswer, retained); assert.equal(c.messages[0].content, raw);
|
|
const secondSources = [{ number: 1, title: 'New turn source', page: 41 }, { number: 2, title: 'New second source', page: 59 }];
|
|
const second = table + '\n\nCompare a < b > c, https://example.test/a?x=1&y=2 [1].';
|
|
let finish, sent;
|
|
c.openAssistantStream = async payload => {
|
|
sent = payload;
|
|
return new Response(new ReadableStream({ start(controller) {
|
|
const send = (event, data) => controller.enqueue(new TextEncoder().encode('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n'));
|
|
send('sources', { sources: secondSources }); send('token', { token: second });
|
|
finish = () => { send('done', { answer: second, sources: secondSources, imageJobs: [{ jobId: id }] }); controller.close(); };
|
|
} }));
|
|
};
|
|
c.bindEvents(); app.document.getElementById('assistant-input').value = 'Create a diagram';
|
|
const request = c.onAsk(); await tick();
|
|
assert.equal(sent.message, 'Create a diagram', 'image requests reach the real tool pathway, not a sidebar heuristic');
|
|
assert.deepEqual(JSON.parse(JSON.stringify(sent.history)), [{ role: 'assistant', content: raw }], 'retained display text and image metadata never enter inference');
|
|
const provisional = app.document.querySelector('.assistant-loading-msg') || app.document.querySelectorAll('.assistant-msg.assistant')[1];
|
|
const current = provisional.querySelector('.assistant-bubble');
|
|
current.querySelector('.assistant-cite').click();
|
|
assert.match(app.document.querySelector('#assistant-source-2').textContent, /New second source.*59/is);
|
|
finish(); await request; await tick();
|
|
assert.equal(c.messages.at(-1).content, second);
|
|
const lastTable = current.querySelector('table'); const lastHtml = lastTable.outerHTML;
|
|
await tick(); assert.equal(current.querySelector('table'), lastTable); assert.equal(lastTable.outerHTML, lastHtml);
|
|
bubble(app).querySelector('.assistant-cite').click();
|
|
assert.match(app.document.querySelector('#assistant-source-2').textContent, /Synthetic B.*19/is);
|
|
await c.performAutosave(); const saved = app.saves.at(-1);
|
|
assert.equal(saved.messages[0].content, raw); assert.equal(saved.messages[0].retainedAnswer, retained); assert.equal(saved.messages[0].legacyClipped, true);
|
|
assert.deepEqual(saved.messages[0].sources, sources);
|
|
// Sources are reordered into citation order on the final render, and anything
|
|
// retrieved but never cited is marked so the panel can say so. This answer
|
|
// cites only the first, so the second is carried along as "not cited".
|
|
assert.deepEqual(saved.sources,
|
|
[secondSources[0], Object.assign({}, secondSources[1], { uncited: true })]);
|
|
assert.deepEqual(saved.messages[0].imageJobs, [{ jobId: id }]); assert.equal(saved.messages.at(-1).content, second);
|
|
c.restoreSavedChat(saved); await tick();
|
|
const before = JSON.stringify(c.messages);
|
|
await c.exporter.exportAnswerPdf({ messages: c.messages, lastAnswer: c.lastAnswer, lastSources: c.lastSources });
|
|
const modal = app.document.querySelector('#assistant-export-modal');
|
|
assert.equal(modal.querySelectorAll('img').length, 2);
|
|
for (const img of modal.querySelectorAll('img')) assert.equal(img.getAttribute('src'), dataUrl);
|
|
assert.equal(modal.querySelectorAll('tbody tr').length, 454);
|
|
assert.match(modal.textContent, /retained.*lastAnswer/i); assert.match(modal.textContent, /New second source, page 59/);
|
|
assert.equal(modal.querySelector('[align=right]').getAttribute('align'), 'right');
|
|
assert.equal(app.window.getComputedStyle(modal.querySelector('[align=right]')).textAlign, 'right');
|
|
const link = modal.querySelector('.assistant-cite[href="#ref-2-2"]'); const reference = modal.querySelector('#ref-2-2');
|
|
let scrolled = 0; reference.scrollIntoView = () => { scrolled++; };
|
|
link.dispatchEvent(new app.window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
assert.equal(scrolled, 1); assert.equal(app.document.activeElement, reference); assert.equal(modal.isConnected, true);
|
|
app.window.dispatchEvent(new app.window.PopStateEvent('popstate')); assert.equal(modal.isConnected, false);
|
|
assert.equal(JSON.stringify(c.messages), before, 'private preparation and display provenance leave canonical messages unchanged');
|
|
});
|
|
|
|
test('saved chats are grouped by recency the way Open WebUI groups them', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8');
|
|
const start = src.indexOf('var SAVED_CHAT_MONTHS');
|
|
const end = src.indexOf(' function renderSavedChats(chats) {');
|
|
assert.ok(start > 0 && end > start, 'grouping helpers located');
|
|
const pinned = new Set(['pin-me']);
|
|
const scope = new Function('isChatPinned', src.slice(start, end) + '; return { savedChatGroup, groupSavedChats };')(
|
|
id => pinned.has(id));
|
|
|
|
const now = new Date('2026-09-09T12:00:00Z');
|
|
const label = value => scope.savedChatGroup(value, now).label;
|
|
assert.equal(label('2026-09-09T08:00:00Z'), 'Today');
|
|
assert.equal(label('2026-09-08T09:00:00Z'), 'Yesterday');
|
|
assert.equal(label('2026-09-07T10:00:00Z'), 'Previous 3 days');
|
|
assert.equal(label('2026-09-04T10:00:00Z'), 'Previous 7 days');
|
|
assert.equal(label('2026-08-20T10:00:00Z'), 'Previous 30 days');
|
|
assert.equal(label('2026-07-15T10:00:00Z'), 'July', 'older than a month falls back to the calendar month');
|
|
assert.equal(label('2025-08-15T10:00:00Z'), 'August 2025', 'a previous year is named');
|
|
assert.equal(label(null), 'Undated', 'a chat with no timestamp is not dropped');
|
|
|
|
const groups = scope.groupSavedChats([
|
|
{ id: 'a', updated_at: '2026-07-15T10:00:00Z' },
|
|
{ id: 'pin-me', updated_at: '2026-07-15T10:00:00Z' },
|
|
{ id: 'b', updated_at: '2026-09-09T09:00:00Z' },
|
|
{ id: 'c', updated_at: '2026-09-08T09:00:00Z' }
|
|
], now);
|
|
assert.deepEqual(groups.map(g => g.label), ['Pinned', 'Today', 'Yesterday', 'July'],
|
|
'pinned first, then newest to oldest');
|
|
assert.deepEqual(groups[0].chats.map(c => c.id), ['pin-me']);
|
|
|
|
const older = scope.groupSavedChats([
|
|
{ id: 'x', updated_at: '2025-12-02T10:00:00Z' },
|
|
{ id: 'y', updated_at: '2026-07-15T10:00:00Z' }
|
|
], now);
|
|
assert.deepEqual(older.map(g => g.label), ['July', 'December 2025'], 'months run newest first');
|
|
});
|