Saved chats Grouped the way Open WebUI groups them: Pinned, Today, Yesterday, Previous 3 days, Previous 7 days, Previous 30 days, then calendar months (with the year once it is not the current one). Chats with no usable timestamp land in Undated rather than disappearing. Sidebar toggle The collapse button used <i class="fas fa-sidebar">, which is a Font Awesome PRO icon; on the Free 6.5.0 build this app loads it rendered nothing, so the toggle has been an invisible button since it shipped. It now uses fa-table-columns, keeps aria-expanded/aria-controls in sync, flips its label between "Hide saved chats" and "Show saved chats" so the collapsed state is escapable, and animates. Acknowledgements "Окей", "Nice", "Perfect", "gracias" and friends now get the existing "What clinical question would you like me to look up?" reply instead of a retrieval and a paid generation. "yes", "sure", "no" and "more" are deliberately excluded: answers end by offering more detail, so those must still be answered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
468 lines
29 KiB
JavaScript
468 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);
|
|
assert.deepEqual(rows(bubble(app)), [['Alpha', '1.25', 'A - B', 'src'], ['Beta', '2-4', 'unchanged', 'src']]);
|
|
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], 'src');
|
|
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], 'src');
|
|
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], 'src');
|
|
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); assert.deepEqual(saved.sources, secondSources);
|
|
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');
|
|
});
|