Pediatric Clinical Assistant
@@ -67,21 +61,33 @@
diff --git a/public/css/assistant.css b/public/css/assistant.css
index 67641a7d..8d6b886b 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -18,9 +18,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; }
.assistant-new-chat:hover { border-color:var(--blue); background:var(--blue-light); }
.assistant-main { display:flex; flex-direction:column; min-height:calc(100vh - 200px); min-width:0; }
-#assistant-chat-view { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 200px); min-width:0; }
-.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
-.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
+#assistant-chat-view { display:grid; grid-template-rows:minmax(0,1fr) auto; min-height:0; min-width:0; }
.assistant-messages { padding:16px; max-height:65vh; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
.assistant-empty i { font-size:34px; color:var(--blue); margin-bottom:10px; }
@@ -191,7 +189,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
/* Saved chats occupy the full left rail and scroll inside it */
.assistant-history { display:flex; flex-direction:column; height:100%; min-height:0; }
.assistant-history .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
-@media (max-width: 960px) { .assistant-layout { grid-template-columns:minmax(0,1fr) 300px; } .assistant-history { position:static; grid-column:1 / -1; grid-template-columns:auto 1fr; align-items:start; } .assistant-new-chat { grid-column:1; } .assistant-view-switch { grid-column:1; } .assistant-history .card { grid-column:2; grid-row:1 / span 2; } .assistant-main { min-height:auto; } #assistant-chat-view { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
+@media (max-width: 960px) { .assistant-layout { grid-template-columns:minmax(0,1fr) 300px; } .assistant-history { position:static; grid-column:1 / -1; grid-template-columns:auto 1fr; align-items:start; } .assistant-new-chat { grid-column:1; } .assistant-view-switch { grid-column:1; } .assistant-history .card { grid-column:2; grid-row:1 / span 2; } .assistant-main { min-height:auto; } #assistant-chat-view { min-height:auto; grid-template-rows:minmax(0,1fr) auto; } }
@media (max-width: 640px) {
/* ── ChatGPT/OWUI mobile: independent full-screen overlay; the page behind never scrolls ── */
.assistant-topbar { flex-wrap:nowrap; gap:8px; padding:10px 14px; }
@@ -209,11 +207,6 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
.assistant-messages { flex:1 1 auto; min-height:0; max-height:none; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:8px 14px 12px; }
.assistant-side { display:none; }
/* Slim toolbar: icon-only actions in one row */
- .assistant-toolbar { padding:6px 10px; flex:0 0 auto; }
- .assistant-toolbar > div:first-child { display:none; }
- .assistant-toolbar-actions { display:flex; width:100%; justify-content:flex-end; gap:4px; }
- .assistant-toolbar-actions .btn-sm { font-size:0; padding:6px 8px; }
- .assistant-toolbar-actions .btn-sm i { font-size:13px; }
/* Saved chats: a fixed drawer overlaying the chat */
.assistant-history { position:fixed; left:0; top:0; bottom:0; width:min(85vw,320px); z-index:130; background:white; border-right:1px solid var(--g100); box-shadow:0 0 40px rgba(15,23,42,.14); transform:translateX(-105%); transition:transform .18s ease; display:flex; flex-direction:column; align-items:stretch; padding:10px 8px; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; gap:2px; border-radius:0; }
.assistant-history .card, #assistant-saved-chats { width:100%; max-width:100%; min-width:0; }
@@ -295,12 +288,13 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1
@keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } }
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
-body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
+/* 66px is the app header, which the assistant now keeps (see styles.css). */
+body.assistant-workspace .assistant-layout { height: calc(100vh - 66px); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
/* Phones: workspace mode hides .app-header, so there is no 64px to subtract and
the mobile flex layout must survive the more specific workspace selectors. */
@media (max-width: 640px) {
- body.assistant-workspace .assistant-layout { height:100dvh; display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
- body.assistant-workspace .assistant-learning-view { height:100dvh; }
+ body.assistant-workspace .assistant-layout { height:calc(100dvh - 66px); display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
+
}
/* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */
.assistant-layout { transition:grid-template-columns .18s ease; }
@@ -466,3 +460,46 @@ body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-colu
body.assistant-mode-workspace .assistant-workspace-view { display:none; }
body.assistant-mode-workspace #assistant-chat-view { display:flex; }
}
+
+/* Rail menu in workspace mode: the app's own .tab-btn shape, so the assistant
+ rail and the app sidebar are the same list in the same clothes. */
+.assistant-rail-workspace { display:none; flex-direction:column; gap:1px; }
+body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
+.assistant-rail-links { display:flex; flex-direction:column; gap:1px; }
+.assistant-rail-link { display:flex; align-items:center; gap:10px; width:100%; border:none; border-left:3px solid transparent; background:none; color:var(--g600); font-family:inherit; font-size:13px; font-weight:500; text-align:left; padding:10px 12px; cursor:pointer; }
+.assistant-rail-link:hover { background:var(--g50); color:var(--g900); }
+.assistant-rail-link i { width:16px; text-align:center; flex-shrink:0; font-size:14px; color:var(--g400); }
+.assistant-rail-link:hover i { color:var(--blue); }
+
+/* ── Composer ────────────────────────────────────────────────────────────────
+ With the toolbar gone, both the chat and the workspace launcher start at the
+ very top of the same box, so switching moves nothing. */
+.assistant-composer-right { display:flex; align-items:center; gap:6px; margin-left:auto; }
+.assistant-tools { display:flex; align-items:center; gap:6px; min-width:0; }
+
+/* The + holds everything that acts on the conversation. */
+.assistant-plus { position:relative; }
+.assistant-plus-btn { display:inline-flex; align-items:center; justify-content:center; width:32px; height:32px; border:1px solid var(--g200); border-radius:999px; background:white; color:var(--g600); font-size:13px; cursor:pointer; }
+.assistant-plus-btn:hover, .assistant-plus-btn[aria-expanded="true"] { border-color:var(--blue); background:var(--blue-light); color:var(--blue); }
+.assistant-plus-menu { position:absolute; bottom:calc(100% + 8px); left:0; z-index:40; min-width:210px; display:flex; flex-direction:column; gap:2px; padding:6px; background:white; border:1px solid var(--g200); border-radius:12px; box-shadow:0 12px 32px rgba(15,23,42,.16); }
+.assistant-plus-menu[hidden] { display:none; }
+.assistant-plus-item { display:flex; align-items:center; gap:10px; width:100%; border:0; border-radius:8px; padding:8px 10px; background:none; color:var(--g700); font-family:inherit; font-size:13px; text-align:left; cursor:pointer; }
+.assistant-plus-item:hover { background:var(--blue-light); color:var(--blue); }
+.assistant-plus-item i { width:16px; text-align:center; color:var(--g400); font-size:12px; }
+.assistant-plus-item:hover i { color:var(--blue); }
+
+/* Empty state: the tiled ground belongs to the whole view, so it runs behind
+ AND below the composer instead of stopping at the transcript's edge. The
+ composer floats on it as a raised card, sitting a little above centre. */
+#assistant-chat-view:has(.assistant-messages:not(:has(.assistant-msg))) {
+ background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px);
+ background-size:28px 28px; background-position:-1px -1px; }
+.assistant-messages:not(:has(.assistant-msg)) { display:flex; flex-direction:column; justify-content:flex-end;
+ background:none; background-image:none; padding-bottom:0; }
+.assistant-empty { max-width:680px; margin:auto auto 22px; text-align:center; color:var(--g500); }
+#assistant-chat-view:has(.assistant-messages:not(:has(.assistant-msg))) .assistant-composer {
+ border-top:none; background:white; margin:0 auto 12vh; max-width:760px; width:calc(100% - 32px); }
+@media (max-width:640px) {
+ #assistant-chat-view:has(.assistant-messages:not(:has(.assistant-msg))) .assistant-composer {
+ margin:0 10px 10px; width:auto; max-width:none; }
+}
diff --git a/public/css/styles.css b/public/css/styles.css
index 206b7861..8ea8c6a7 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -1202,10 +1202,12 @@ textarea.full-input{resize:vertical;}
}
/* Open WebUI-style assistant workspace: saved chats replace the main menu */
-body.assistant-workspace .app-header,
+/* The blue app header stays: it is the same product, and hiding it was the last
+ thing making the assistant look like a separate one. Only the app SIDEBAR goes,
+ because the assistant rail takes its place. */
body.assistant-workspace .sidebar,
body.assistant-workspace .announcement-banner { display:none; }
-body.assistant-workspace .app-body { min-height:100vh; }
+body.assistant-workspace .app-body { min-height:calc(100vh - 66px); }
body.assistant-workspace #assistant-tab.active { width:100%; }
/* iOS: taps on controls must not trigger double-tap zoom */
button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select, label { touch-action: manipulation; }
@@ -1248,3 +1250,12 @@ body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout {
body.assistant-workspace.menu-hidden .assistant-history > *:not(.assistant-menu-toggle) { display:none; }
body.assistant-workspace.menu-hidden .assistant-history { width:0; min-width:0; overflow:visible; padding:0; }
@media (max-width:640px) { .assistant-menu-toggle { display:none; } body.menu-hidden .sidebar { width:auto; } }
+
+/* Signed-out preview: the assistant is usable, everything that needs an account
+ is not shown rather than shown and refused. */
+body.assistant-preview .assistant-plus,
+body.assistant-preview .assistant-history .card,
+body.assistant-preview .assistant-rail-actions,
+body.assistant-preview .assistant-chats-header { display:none !important; }
+body.assistant-preview .assistant-preview-note { display:flex; }
+.assistant-preview-note { display:none; align-items:center; gap:8px; margin:0 0 10px; padding:8px 12px; border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:10px; font-size:12px; }
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js
index 59070dbd..8608e940 100644
--- a/public/js/admin/clinicalAssistant.js
+++ b/public/js/admin/clinicalAssistant.js
@@ -150,8 +150,14 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
- var citationsBox = document.getElementById('assistant-citations-enabled');
- if (citationsBox) citationsBox.checked = String(cfg['clinical_assistant.citations_enabled']) !== 'false';
+ var sourcesBox = document.getElementById('assistant-show-sources');
+ if (sourcesBox) {
+ var saved = cfg['clinical_assistant.show_sources'];
+ if (saved === undefined || saved === '') saved = cfg['clinical_assistant.citations_enabled']; // legacy key
+ sourcesBox.checked = String(saved) !== 'false';
+ }
+ var previewBox = document.getElementById('assistant-preview-enabled');
+ if (previewBox) previewBox.checked = String(cfg['clinical_assistant.preview_enabled']) === 'true';
var budgetInput = document.getElementById('assistant-conversation-budget');
var cfgBudget = cfg['clinical_assistant.conversation_chars'];
if (budgetInput) {
@@ -382,8 +388,10 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'),
putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'),
putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'),
- putAssistantConfig('clinical_assistant.citations_enabled',
- (document.getElementById('assistant-citations-enabled') || {}).checked === false ? 'false' : 'true'),
+ putAssistantConfig('clinical_assistant.show_sources',
+ (document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true'),
+ putAssistantConfig('clinical_assistant.preview_enabled',
+ (document.getElementById('assistant-preview-enabled') || {}).checked === true ? 'true' : 'false'),
putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')),
putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(','))
diff --git a/public/js/app.js b/public/js/app.js
index bf8d3013..0a81f392 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -170,6 +170,13 @@ document.addEventListener('DOMContentLoaded', function() {
if (!pill) return;
var wantsAssistant = pill.getAttribute('data-assistant-mode') === 'assistant';
var onAssistant = window.location.pathname === '/assistant';
+ // A preview visitor may try the assistant; the workspace is the product, so
+ // reaching for it is where we ask them to sign in.
+ if (document.body.classList.contains('assistant-preview') && !wantsAssistant) {
+ var screen = document.getElementById('auth-screen');
+ if (screen) screen.style.display = 'flex';
+ return;
+ }
if (!onAssistant) {
// In the app you ARE the workspace, so only the Assistant pill navigates.
if (wantsAssistant) window.location.href = '/assistant';
diff --git a/public/js/auth.js b/public/js/auth.js
index fcc0516d..c869f48b 100644
--- a/public/js/auth.js
+++ b/public/js/auth.js
@@ -176,7 +176,30 @@ document.addEventListener('DOMContentLoaded', function() {
// Auth screen is hidden by CSS default — only show it when there is no valid session
function showAuthScreen() {
- if (!boundary.blocked() && !window.CURRENT_USER && authScreen) authScreen.style.display = 'flex';
+ if (boundary.blocked() || window.CURRENT_USER || !authScreen) return;
+ // Admin-enabled preview: a signed-out visitor at /assistant may try the
+ // assistant instead of meeting a wall. Everything else still needs an
+ // account, and the server enforces that independently — this only decides
+ // whether to show the login screen.
+ if (window.location && window.location.pathname === '/assistant') {
+ fetch('/api/clinical-assistant/status', { credentials: 'same-origin' })
+ .then(function(r) { return r.ok ? r.json() : null; })
+ .then(function(data) {
+ if (data && data.success && data.preview) return enterPreview();
+ authScreen.style.display = 'flex';
+ })
+ .catch(function() { authScreen.style.display = 'flex'; });
+ return;
+ }
+ authScreen.style.display = 'flex';
+ }
+
+ // The auth screen is an overlay over an already-rendered app, so entering
+ // preview is simply declining to raise it.
+ function enterPreview() {
+ document.body.classList.add('assistant-preview');
+ if (authScreen) authScreen.style.display = 'none';
+ if (typeof window.activateTab === 'function') window.activateTab('assistant');
}
// ── Check for SSO redirect (token is in httpOnly cookie) ──
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 36952e15..1fd93436 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -172,20 +172,35 @@ import {
var renderWorkspaceCards = function() {
var cards = document.getElementById('assistant-workspace-cards');
- if (!cards) return;
- cards.innerHTML = '';
+ var links = document.getElementById('assistant-workspace-links');
+ if (!cards && !links) return;
+ if (cards) cards.innerHTML = '';
+ if (links) links.innerHTML = '';
Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
var name = tab.getAttribute('data-tab');
- if (!name || tab.classList.contains('hidden') || NON_WORK_TABS.indexOf(name) !== -1) return;
+ if (!name || tab.classList.contains('hidden')) return;
var icon = tab.querySelector('i');
var label = tab.querySelector('span');
- var card = document.createElement('button');
- card.type = 'button';
- card.className = 'assistant-workspace-card';
- card.setAttribute('data-assistant-workspace-tab', name);
- card.innerHTML = '
' +
+ var body = '
' +
'
' + escapeHtml(label ? label.textContent : name) + '';
- cards.appendChild(card);
+ // The rail mirrors the whole menu; the cards are clinical work only, so
+ // Settings and friends stay reachable without becoming launch tiles.
+ if (links) {
+ var link = document.createElement('button');
+ link.type = 'button';
+ link.className = 'assistant-rail-link';
+ link.setAttribute('data-assistant-workspace-tab', name);
+ link.innerHTML = body;
+ links.appendChild(link);
+ }
+ if (cards && NON_WORK_TABS.indexOf(name) === -1) {
+ var card = document.createElement('button');
+ card.type = 'button';
+ card.className = 'assistant-workspace-card';
+ card.setAttribute('data-assistant-workspace-tab', name);
+ card.innerHTML = body;
+ cards.appendChild(card);
+ }
});
};
@@ -209,6 +224,32 @@ import {
if (typeof window.activateTab === 'function') window.activateTab(card.getAttribute('data-assistant-workspace-tab'));
});
+ // The + menu holds everything that acts on the conversation, so the top of
+ // the view can stay empty and both modes line up.
+ var plusBtn = document.getElementById('btn-assistant-plus');
+ var plusMenu = document.getElementById('assistant-plus-menu');
+ var closePlus = function() {
+ if (!plusMenu) return;
+ plusMenu.hidden = true;
+ if (plusBtn) plusBtn.setAttribute('aria-expanded', 'false');
+ };
+ if (plusBtn && plusMenu) {
+ plusBtn.addEventListener('click', function(event) {
+ event.stopPropagation();
+ var open = plusMenu.hidden;
+ plusMenu.hidden = !open;
+ plusBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ // Choosing an item, clicking away or pressing Escape all dismiss it.
+ plusMenu.addEventListener('click', function() { closePlus(); });
+ onceOnDocument('click', function(event) {
+ if (!event.target.closest || !event.target.closest('.assistant-plus')) closePlus();
+ });
+ onceOnDocument('keydown', function(event) {
+ if (event.key === 'Escape') closePlus();
+ });
+ }
+
var chatsToggle = document.getElementById('btn-assistant-chats-toggle');
if (chatsToggle) chatsToggle.addEventListener('click', function() {
var list = document.getElementById('assistant-saved-chats');
@@ -299,13 +340,9 @@ import {
function loadStatus() {
return fetchAssistantStatus()
.then(function (data) {
- var label = document.getElementById('assistant-model-label');
- if (label && data.success) {
- label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
- }
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
if (data.success && data.translateProvider === 'libretranslate') translateProvider = data.translateProvider;
- if (data.success) applyCitationMode(data.citationsEnabled !== false);
+ if (data.success) applyCitationMode(data.showSources !== false);
if (data.success) {
statusChoices = {
allowedChatModels: data.allowedChatModels || [],
@@ -512,10 +549,6 @@ import {
replaceLoadingMessage(loading, lastAnswer, lastSources, finalData.suggestions || []);
attachImageJobs(loading, messages[messages.length - 1], finalData.imageJobs || []);
renderSources(lastSources);
- if (finalData.model) {
- var label = document.getElementById('assistant-model-label');
- if (label) label.textContent = 'Chat: ' + finalData.model;
- }
}
function renderStreamingAnswerHtml(text, sources) {
@@ -771,9 +804,10 @@ import {
return null;
}
- // Admin switch: with citations off the server sends no sources and strips the
- // [n] markers, so the panel would only ever show its empty state. The layout
- // gives the column back to the chat instead of leaving a blank rail.
+ // Display-only admin switch: with sources off the server sends none and strips
+ // the now-orphaned [n] markers, so the panel would only ever show its empty
+ // state. The layout gives the column back to the chat instead of a blank rail.
+ // The prompt and the stored answer are unaffected either way.
var citationsOn = true;
function applyCitationMode(enabled) {
diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js
index afe42557..62173167 100644
--- a/src/routes/adminConfig.js
+++ b/src/routes/adminConfig.js
@@ -860,8 +860,11 @@ router.put('/config/:key(*)', async function(req, res) {
return res.status(400).json({ error: 'Conversation budget must be an integer between 1000 and 1000000 UTF-16 code units, or empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS' });
}
}
- if (key === 'clinical_assistant.citations_enabled' && !['true', 'false'].includes(String(value))) {
- return res.status(400).json({ error: 'Citation mode must be true or false' });
+ if (key === 'clinical_assistant.preview_enabled' && !['true', 'false'].includes(String(value))) {
+ return res.status(400).json({ error: 'Preview mode must be true or false' });
+ }
+ if (key === 'clinical_assistant.show_sources' && !['true', 'false'].includes(String(value))) {
+ return res.status(400).json({ error: 'Show sources must be true or false' });
}
if (key.startsWith('prompt.') || promptCatalog.find(key)) {
if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' });
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index c55d345d..d2d86d16 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -45,6 +45,40 @@ var translateLanguageCache = clinicalTranslation.createLanguageCache();
var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts');
+// ── Signed-out preview (admin opt-in, default OFF) ──────────────────────────
+// Deliberately narrow. A preview visitor may ask questions and read the answer;
+// they get NO identity, so nothing can be owned, saved, billed or addressed to
+// them. Everything else on this router still requires real authentication,
+// which is enforced by allow-listing exact paths rather than by pattern —
+// a new route is private unless someone adds it here on purpose.
+var PREVIEW_PATHS = new Set([
+ '/clinical-assistant/status',
+ '/clinical-assistant/examples',
+ '/clinical-assistant/chat',
+ '/clinical-assistant/chat/stream'
+]);
+var PREVIEW_USER = Object.freeze({ id: null, preview: true, role: 'preview' });
+
+async function previewEnabled() {
+ return String(await getSetting('clinical_assistant.preview_enabled', 'false')) === 'true';
+}
+
+router.use(async function(req, res, next) {
+ if (req.user) return next();
+ if (!PREVIEW_PATHS.has(req.path)) return authMiddleware(req, res, next);
+ try {
+ if (!(await previewEnabled())) return authMiddleware(req, res, next);
+ } catch (_) {
+ return authMiddleware(req, res, next); // a lookup failure must not open the door
+ }
+ // An authenticated caller keeps their own identity; this only covers the
+ // no-credential case, so preview can never downgrade a real session.
+ var hasCredential = Boolean((req.headers.authorization || '').startsWith('Bearer ') || (req.cookies && req.cookies.ped_auth));
+ if (hasCredential) return authMiddleware(req, res, next);
+ req.user = PREVIEW_USER;
+ next();
+});
+
router.use(authMiddleware);
var MAX_SAVED_CHATS_PER_USER = 100;
@@ -105,7 +139,9 @@ router.get('/clinical-assistant/status', async function(req, res) {
conversationSource: budget.source,
conversationMeasure: budget.measure,
translateProvider: translateProvider,
- citationsEnabled: await citationsEnabled(),
+ showSources: await showSourcesEnabled(),
+ previewEnabled: await previewEnabled(),
+ preview: Boolean(req.user && req.user.preview),
mcp: mcpHealth
});
} catch (e) {
@@ -302,6 +338,12 @@ router.post('/clinical-assistant/patient-takehome/email', async function(req, re
res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Could not send the email', code: e.code });
}
});
+function refusePreviewWrite(req, res) {
+ if (!req.user || !req.user.preview) return false;
+ res.status(401).json({ error: 'Create an account or sign in to use this', preview: true });
+ return true;
+}
+
router.post('/clinical-assistant/chat', async function(req, res) {
var started = Date.now();
try {
@@ -311,13 +353,13 @@ router.post('/clinical-assistant/chat', async function(req, res) {
var ai = await callAI(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
- tools: imageTool.tools,
+ tools: req.user.preview ? undefined : imageTool.tools,
maxTokens: 2600,
images: prepared.images
}));
- ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
+ if (!req.user.preview) ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
- ai = await dispatchImageRequestFallback(ai, prepared, req);
+ if (!req.user.preview) ai = await dispatchImageRequestFallback(ai, prepared, req);
var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, {
messages: prepared.messages,
chatModel: prepared.chatModel,
@@ -327,16 +369,16 @@ router.post('/clinical-assistant/chat', async function(req, res) {
var answer = finalized.answer;
ai = finalized.ai;
- logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
+ if (!req.user.preview) logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
});
res.json({
success: true,
- answer: prepared.citations ? answer : stripCitationMarkers(answer),
+ answer: prepared.showSources ? answer : stripCitationMarkers(answer),
imageJobs: ai.imageJobs || [],
- citations: prepared.citations,
- sources: prepared.citations ? sanitizeSourcesForClient(prepared.sources) : [],
+ showSources: prepared.showSources,
+ sources: prepared.showSources ? sanitizeSourcesForClient(prepared.sources) : [],
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
@@ -379,16 +421,18 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
var ai = await callAIStream(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
- tools: imageTool.tools,
+ tools: req.user.preview ? undefined : imageTool.tools,
maxTokens: 2600,
images: prepared.images
}), function(delta) {
sendEvent('token', { token: delta });
});
- ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
- imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
- ai = await dispatchImageRequestFallback(ai, prepared, req);
+ if (!req.user.preview) {
+ ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
+ imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
+ ai = await dispatchImageRequestFallback(ai, prepared, req);
+ }
if (ai.imageToolHandled) sendEvent('status', { message: 'Generating image…' });
var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, {
messages: prepared.messages,
@@ -401,15 +445,15 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
var answer = finalized.answer;
ai = finalized.ai;
- logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant streaming query', req, {
+ if (!req.user.preview) logger.audit(req.user.id, 'clinical_assistant_streaming_query', 'Clinical assistant streaming query', req, {
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
});
sendEvent('done', {
success: true,
- answer: prepared.citations ? answer : stripCitationMarkers(answer),
+ answer: prepared.showSources ? answer : stripCitationMarkers(answer),
imageJobs: ai.imageJobs || [],
- citations: prepared.citations,
- sources: prepared.citations ? safeSources : [],
+ showSources: prepared.showSources,
+ sources: prepared.showSources ? safeSources : [],
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
@@ -536,7 +580,7 @@ async function prepareAssistantChat(body) {
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR;
var includeContext = body.includeContext !== false;
- var citations = await citationsEnabled();
+ var showSources = await showSourcesEnabled();
var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
@@ -569,7 +613,7 @@ async function prepareAssistantChat(body) {
var context = formatSourcesForPrompt(sources);
return {
message: message,
- citations: citations,
+ showSources: showSources,
images: images,
imageContext: generatedImages.imageContext(message, history),
history: history,
@@ -577,7 +621,7 @@ async function prepareAssistantChat(body) {
imageModel: imageModel,
sources: sources,
messages: [
- { role: 'system', content: buildSystemPrompt(behavior, { citations: citations }) },
+ { role: 'system', content: buildSystemPrompt(behavior) },
{ role: 'user', content: buildUserPrompt(message, context, history, searchQuery) }
],
search: {
@@ -746,10 +790,17 @@ function isUsefulIndexedTopicExample(item) {
return true;
}
-// Admin switch. Retrieval, prompts and grounding are unchanged when this is off;
-// only the visible citation markers and the sources panel go away.
-async function citationsEnabled() {
- return String(await getSetting('clinical_assistant.citations_enabled', 'true')) !== 'false';
+// Display-only admin switch. The prompt, retrieval, grounding and the stored
+// answer are all identical either way — branching the prompt on a display
+// setting would change how the model reasons, which is the bias this avoids.
+// Only what the client is sent differs.
+async function showSourcesEnabled() {
+ var value = await getSetting('clinical_assistant.show_sources', '');
+ if (value === '' || value == null) {
+ // Legacy key from when this also changed the prompt.
+ value = await getSetting('clinical_assistant.citations_enabled', 'true');
+ }
+ return String(value) !== 'false';
}
async function getConversationLimit() {
diff --git a/src/utils/clinicalAnswer.js b/src/utils/clinicalAnswer.js
index 53aaaaee..a12e46ce 100644
--- a/src/utils/clinicalAnswer.js
+++ b/src/utils/clinicalAnswer.js
@@ -1,19 +1,15 @@
-// The citation rules are the only difference between the two modes. Everything
-// else — grounding, scope, table formatting, tone — is identical, so turning
-// citations off changes what the answer shows, not how it is produced.
-var CITED_RULES = '\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3]. Never escape citation brackets: write [1], not \\[1\\]; reserved LaTeX delimiters are not citations.\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- If a table has a Source, Source(s), Citation, or Citation(s) column, every cell in that column must use bracketed citation tokens like [1] or [1, 3], never bare numbers like 1 or 1, 3.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- Do not cite a source number that is not provided.';
-var UNCITED_RULES = '\n- Do not include citations, source numbers, bracketed reference markers like [1], footnote markers, or a Source/Citation column in tables.\n- Every factual claim must still come from the retrieved sources; the requirement is unchanged, only the visible marker is dropped.';
-
-function buildSystemPrompt(behavior, options) {
- var citations = !options || options.citations !== false;
- return behavior + '\n\nIf the user asks for an image, call the generate_image tool with a self-contained prompt and briefly say you are preparing the image; the image appears automatically. Do not merely write an image prompt.\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.' +
- (citations ? CITED_RULES : UNCITED_RULES) +
- '\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section' + (citations ? '; the UI displays all retrieved sources separately.' : '.') + '\n- Do not add generic disclaimers about clinician judgment.';
+// The prompt is IDENTICAL whether or not sources are displayed. Branching it
+// would change how the model reasons and cites, so the same question could get a
+// different answer depending on a display setting — exactly the bias this must
+// avoid. Hiding sources is a presentation decision, applied after generation.
+function buildSystemPrompt(behavior) {
+ return behavior + '\n\nIf the user asks for an image, call the generate_image tool with a self-contained prompt and briefly say you are preparing the image; the image appears automatically. Do not merely write an image prompt.\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3]. Never escape citation brackets: write [1], not \\[1\\]; reserved LaTeX delimiters are not citations.\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- If a table has a Source, Source(s), Citation, or Citation(s) column, every cell in that column must use bracketed citation tokens like [1] or [1, 3], never bare numbers like 1 or 1, 3.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- Do not cite a source number that is not provided.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
}
-// A model can still emit [1] against instructions. With citations off there is
-// no sources panel to link them to, so a stray marker would render as literal
-// text — strip them from the stored answer, not just the view.
+// With the sources panel hidden the [n] markers point at nothing, so they are
+// removed from the copy that is DISPLAYED. The answer is generated, stored and
+// exported with its citations intact, so turning the setting back on restores
+// them without re-asking anything.
function stripCitationMarkers(answer) {
return String(answer || '')
.replace(/(?:\s*\[(?:\d+\s*,\s*)*\d+\])+/g, '')
diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js
index 725824eb..1ea992c7 100644
--- a/test/admin-clinical-assistant-wiring.test.js
+++ b/test/admin-clinical-assistant-wiring.test.js
@@ -70,9 +70,9 @@ test('native admin initializer preserves lazy navigation, assistant actions and
const writes = () => calls.filter(c => c.options.method === 'PUT');
const save = document.getElementById('btn-save-assistant-config');
save.click(); await tick();
- assert.equal(writes().length, 8);
+ assert.equal(writes().length, 9);
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
- 'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', 'clinical_assistant.citations_enabled', 'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider'
+ 'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.preview_enabled', 'clinical_assistant.search_limit', 'clinical_assistant.show_sources', 'clinical_assistant.translate_provider'
]);
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js
index a7400543..5a66cd6c 100644
--- a/test/assistant-citations.test.js
+++ b/test/assistant-citations.test.js
@@ -293,57 +293,45 @@ test('mermaid source survives the sanitiser and round-trips', async () => {
assert.equal(decodeURIComponent(attr[1].replace(/&/g, '&')).trim(), flowchart);
});
-// ── Admin citation mode ────────────────────────────────────────────────────
-test('citation mode changes only the citation rules, never the grounding', () => {
+// ── Admin sources display ──────────────────────────────────────────────────
+test('the prompt is identical whether or not sources are displayed', () => {
const { buildSystemPrompt } = require('../src/utils/clinicalAnswer');
- const on = buildSystemPrompt('BEHAVIOR');
- const off = buildSystemPrompt('BEHAVIOR', { citations: false });
-
- assert.match(on, /Cite factual claims immediately with numbered citations/);
- assert.doesNotMatch(off, /Cite factual claims immediately/, 'the [n] rules are dropped');
- assert.match(off, /Do not include citations, source numbers, bracketed reference markers/);
- assert.match(off, /the requirement is unchanged, only the visible marker is dropped/);
-
- // Everything that makes the answer trustworthy must be identical in both.
- for (const shared of [
- /Answer only the user question/,
- /not directly supported by retrieved sources/,
- /do not claim that another source is from the named source/,
- /valid GitHub-flavored markdown table/,
- /Do not add generic disclaimers/
- ]) {
- assert.match(on, shared);
- assert.match(off, shared, 'grounding and formatting rules are the same in both modes');
- }
- assert.equal(buildSystemPrompt('BEHAVIOR', {}), on, 'citations default to on');
- assert.equal(buildSystemPrompt('BEHAVIOR', { citations: true }), on);
+ // Branching the prompt on a display setting would change how the model
+ // reasons and cites, so the same question could get a different answer
+ // depending on what the admin chose to show. That is the bias this prevents.
+ const base = buildSystemPrompt('BEHAVIOR');
+ assert.equal(buildSystemPrompt('BEHAVIOR', { citations: false }), base, 'options cannot alter it');
+ assert.equal(buildSystemPrompt('BEHAVIOR', { showSources: false }), base);
+ assert.equal(buildSystemPrompt('BEHAVIOR', {}), base);
+ assert.match(base, /Cite factual claims immediately with numbered citations/,
+ 'citations are always requested, so the stored answer keeps them');
});
-test('stray citation markers are stripped from the stored answer, not just the view', () => {
+test('hiding sources is display-only and reversible', () => {
const { stripCitationMarkers } = require('../src/utils/clinicalAnswer');
assert.equal(stripCitationMarkers('Amoxicillin 90 mg/kg/day [1][2]. Reassess in 48 h [3].'),
'Amoxicillin 90 mg/kg/day. Reassess in 48 h.');
assert.equal(stripCitationMarkers('Give fluids [1, 3] and rest [2].'), 'Give fluids and rest.');
assert.equal(stripCitationMarkers('No citations here.'), 'No citations here.');
- // A model that obeys the prompt costs nothing; one that does not must not
- // leave literal "[1]" text with no sources panel to click through to.
- assert.doesNotMatch(stripCitationMarkers('Dose [1] is 90 mg/kg [2]'), /\[\d/);
+
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8');
+ // Stripping happens on the way OUT, so the answer is generated and stored with
+ // citations intact and turning the setting back on restores them.
+ assert.match(route, /answer: prepared\.showSources \? answer : stripCitationMarkers\(answer\)/);
+ assert.match(route, /sources: prepared\.showSources \? sanitizeSourcesForClient\(prepared\.sources\) : \[\]/);
+ assert.match(route, /content: buildSystemPrompt\(behavior\)/, 'the prompt takes no display argument');
});
-test('the admin toggle is a real boolean setting and the server refuses anything else', () => {
+test('the sources toggle is a boolean the server enforces, with the legacy key honoured', () => {
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const admin = fs.readFileSync(path.join(root, 'src/routes/adminConfig.js'), 'utf8');
- assert.match(admin, /clinical_assistant\.citations_enabled' && !\['true', 'false'\]/, 'validated as a boolean');
+ assert.match(admin, /clinical_assistant\.show_sources' && !\['true', 'false'\]/);
const route = fs.readFileSync(path.join(root, 'src/routes/clinicalAssistant.js'), 'utf8');
- assert.match(route, /citations_enabled', 'true'\)\) !== 'false'/, 'defaults to on');
- assert.match(route, /citationsEnabled: await citationsEnabled\(\)/, 'exposed on the status endpoint');
- assert.match(route, /sources: prepared\.citations \? sanitizeSourcesForClient\(prepared\.sources\) : \[\]/,
- 'no sources are sent to the client when citations are off');
- const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8');
- assert.match(html, /id="assistant-citations-enabled"/, 'the admin has a control for it');
- const ui = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
- assert.match(ui, /applyCitationMode\(data\.citationsEnabled !== false\)/, 'the UI follows the server');
- assert.match(ui, /side\.hidden = !citationsOn/, 'and hides the Sources panel');
+ assert.match(route, /clinical_assistant\.citations_enabled', 'true'\)/, 'the old key still applies');
+ assert.match(fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'),
+ /id="assistant-show-sources"/);
});
diff --git a/test/assistant-preview.test.js b/test/assistant-preview.test.js
new file mode 100644
index 00000000..b19b8fa8
--- /dev/null
+++ b/test/assistant-preview.test.js
@@ -0,0 +1,93 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const root = path.join(__dirname, '..');
+const read = file => fs.readFileSync(path.join(root, file), 'utf8');
+
+// Signed-out preview is an admin opt-in that widens who can reach the assistant,
+// so the boundaries matter more than the feature.
+test('preview is off unless an admin turns it on', () => {
+ const route = read('src/routes/clinicalAssistant.js');
+ assert.match(route, /getSetting\('clinical_assistant\.preview_enabled', 'false'\)/,
+ 'default is off');
+ assert.match(route, /=== 'true'/, 'and only the exact string enables it');
+ const admin = read('src/routes/adminConfig.js');
+ assert.match(admin, /clinical_assistant\.preview_enabled' && !\['true', 'false'\]/,
+ 'the server refuses a non-boolean');
+});
+
+test('preview reaches an allow-list of paths, never a pattern', () => {
+ const route = read('src/routes/clinicalAssistant.js');
+ const block = route.slice(route.indexOf('var PREVIEW_PATHS'), route.indexOf('var PREVIEW_USER'));
+ // A pattern would silently include future routes; an exact list means a new
+ // endpoint is private until someone adds it here deliberately.
+ for (const allowed of ['/clinical-assistant/status', '/clinical-assistant/examples',
+ '/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
+ assert.ok(block.includes("'" + allowed + "'"), allowed + ' is previewable');
+ }
+ for (const denied of ['/clinical-assistant/chats', '/clinical-assistant/image',
+ '/clinical-assistant/patient-takehome', '/clinical-assistant/translate']) {
+ assert.ok(!block.includes("'" + denied + "'"), denied + ' must stay private');
+ }
+ assert.match(route, /PREVIEW_PATHS\.has\(req\.path\)/, 'matched exactly, not by prefix');
+});
+
+test('preview never downgrades a real session, and a lookup failure closes the door', () => {
+ const route = read('src/routes/clinicalAssistant.js');
+ const mw = route.slice(route.indexOf('router.use(async function(req, res, next)'), route.indexOf('router.use(authMiddleware);'));
+ assert.match(mw, /if \(req\.user\) return next\(\);/, 'an established identity is untouched');
+ assert.match(mw, /hasCredential[\s\S]*?return authMiddleware/,
+ 'a caller presenting a token is authenticated normally, not previewed');
+ assert.match(mw, /catch \(_\) \{\s*\n\s*return authMiddleware/,
+ 'if the setting cannot be read, authentication is required');
+});
+
+test('a preview visitor has no identity, so nothing can be owned or billed', () => {
+ const route = read('src/routes/clinicalAssistant.js');
+ assert.match(route, /PREVIEW_USER = Object\.freeze\(\{ id: null, preview: true/,
+ 'no user id at all');
+ // Image generation is owned, stored and paid for, so the tool is withheld
+ // rather than left to fail on a null owner downstream.
+ assert.match(route, /tools: req\.user\.preview \? undefined : imageTool\.tools/);
+ // Each dispatch must be reachable only behind a preview check — either inline
+ // on the same statement, or inside an enclosing `if (!req.user.preview) {`.
+ const lines = route.split('\n');
+ lines.forEach((line, i) => {
+ if (!line.includes('imageTool.dispatch(')) return;
+ const guardedInline = line.includes('!req.user.preview');
+ const guardedByBlock = lines.slice(Math.max(0, i - 3), i)
+ .some(l => l.includes('if (!req.user.preview) {'));
+ assert.ok(guardedInline || guardedByBlock,
+ 'unguarded imageTool.dispatch at line ' + (i + 1) + ': ' + line.trim().slice(0, 60));
+ });
+ assert.equal(lines.filter(l => l.includes('imageTool.dispatch(')).length, 2,
+ 'both chat paths are covered');
+ assert.match(route, /if \(!req\.user\.preview\) logger\.audit/,
+ 'audit rows are not written against a null user');
+});
+
+test('preview is entered by declining the login overlay, not by faking a session', () => {
+ const auth = read('public/js/auth.js');
+ const fn = auth.slice(auth.indexOf('function showAuthScreen()'), auth.indexOf('// ── Check for SSO redirect'));
+ assert.match(fn, /pathname === '\/assistant'/, 'only the assistant page previews');
+ assert.match(fn, /data\.success && data\.preview/, 'and only when the server says so');
+ assert.match(fn, /authScreen\.style\.display = 'flex';/, 'anything else raises the login screen');
+ // No token, no user object, no stored credential — the overlay is simply not
+ // shown, so nothing downstream can mistake a visitor for a signed-in user.
+ assert.doesNotMatch(fn, /CURRENT_USER =|SecureStorage\.set/, 'preview never mints an identity');
+});
+
+test('reaching for the workspace is where a preview visitor is asked to sign in', () => {
+ const app = read('public/js/app.js');
+ const handler = app.slice(app.indexOf('// Assistant / Workspace switch'), app.indexOf('window.activateTab = activateTab;'));
+ assert.match(handler, /assistant-preview'\) && !wantsAssistant/);
+ assert.match(handler, /screen\.style\.display = 'flex'/, 'the login screen is raised');
+
+ // Actions that need an account are hidden rather than offered and refused.
+ const css = read('public/css/styles.css');
+ for (const hidden of ['.assistant-plus', '.assistant-history .card', '.assistant-rail-actions']) {
+ assert.ok(css.includes('body.assistant-preview ' + hidden), hidden + ' is hidden in preview');
+ }
+ assert.match(read('public/components/assistant.html'), /assistant-preview-note/, 'and the state is stated plainly');
+});
diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js
index ce623433..df2fb779 100644
--- a/test/assistant-workspace-layout.test.js
+++ b/test/assistant-workspace-layout.test.js
@@ -67,9 +67,17 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back
assert.equal(app.document.querySelector('#btn-assistant-goback'), null, 'no Go back in the topbar');
assert.ok(app.document.querySelector('[data-assistant-mode="workspace"]'), 'the mode switch replaces it');
assert.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top');
- const actions = app.document.querySelector('.assistant-toolbar-actions');
- const buttons = [...actions.querySelectorAll('button')].map(b => b.id);
- assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat', 'btn-assistant-takehome'], 'top bar keeps Export PDF, Download transcript and Patient take home');
+ // The top bar is gone entirely: everything that acts on the conversation moved
+ // into the composer's + menu, so both views start at the same vertical
+ // position and switching cannot nudge the page up or down.
+ assert.equal(app.document.querySelector('.assistant-toolbar'), null, 'no top bar above the transcript');
+ const menu = app.document.querySelector('#assistant-plus-menu');
+ assert.ok(menu, 'the + menu exists');
+ const items = [...menu.querySelectorAll('button')].map(b => b.id);
+ assert.deepEqual(items, ['btn-assistant-takehome', 'btn-assistant-export-pdf', 'btn-assistant-download-chat'],
+ 'Patient take home, Export PDF and Download transcript live in the + menu');
+ assert.ok(menu.querySelector('label[for="assistant-attach-input"]'), 'and attaching images with them');
+ assert.equal(menu.hidden, true, 'closed until asked for');
const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]');
assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen');
});
@@ -163,10 +171,10 @@ test('workspace mode does not override the mobile layout with a desktop header o
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/assistant.css'), 'utf8');
// body.assistant-workspace .assistant-layout is both later in the file and more
// specific than the .assistant-layout rule inside @media (max-width:640px), so
- // without a matching-specificity override phones inherit calc(100vh - 64px) —
- // an offset for the app header that workspace mode has already hidden.
- const override = css.indexOf('body.assistant-workspace .assistant-layout { height:100dvh;');
- const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 64px)');
+ // the mobile rule needs a matching-specificity override to win at all.
+ // Both now subtract the app header, which the assistant keeps rather than hides.
+ const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - 66px);');
+ const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - 66px)');
assert.ok(desktop > 0, 'the desktop workspace rule exists');
assert.ok(override > desktop, 'the mobile override comes after it, so it wins');
const rule = css.slice(override, css.indexOf('}', override));
@@ -283,3 +291,44 @@ test('chrome rendered on the main app page is styled by the stylesheet that page
}
assert.match(styles, /body\.menu-hidden \.sidebar \{ width:0/, 'the collapse state is styled where the app can see it');
});
+
+test('the model selector sits with send, and only when there is a choice', () => {
+ const html = read('public/components/assistant.html');
+ // ChatGPT/OWUI placement: the model belongs beside the send control in the
+ // composer, not in a bar above the transcript.
+ const right = html.slice(html.indexOf('assistant-composer-right'), html.indexOf(''));
+ for (const id of ['assistant-model-pill', 'btn-assistant-send']) {
+ assert.ok(right.includes(id), id + ' is on the composer right');
+ }
+ assert.match(html, /id="assistant-model-pill"[^>]*hidden/, 'hidden until there is something to pick');
+
+ const js = read('public/js/clinicalAssistant.js');
+ assert.match(js, /One model means no choice: hide the selector entirely/);
+ assert.match(js, /var show = Array\.isArray\(allowed\) && allowed\.length > 1;/);
+ assert.match(js, /if \(pill\) pill\.hidden = !show;/, 'the pill follows the select');
+ assert.doesNotMatch(js, /assistant-model-label/, 'the old always-on model label is gone');
+});
+
+test('nothing sits above the transcript, so switching cannot shift the page', () => {
+ const css = read('public/css/assistant.css');
+ // Both views fill the same box from the same top edge. With a toolbar above
+ // one of them, switching moved everything down by its height.
+ assert.match(css, /#assistant-chat-view \{ display:grid; grid-template-rows:minmax\(0,1fr\) auto;/,
+ 'chat is transcript + composer, nothing above');
+ assert.doesNotMatch(css, /^\.assistant-toolbar/m, 'no toolbar styling left');
+ assert.match(css, /\.assistant-workspace-view \{ flex:1 1 auto; min-height:0;/,
+ 'the launcher fills the same box');
+
+ const html = read('public/components/assistant.html');
+ assert.doesNotMatch(html, /assistant-toolbar/, 'and no toolbar markup');
+});
+
+test('the empty state puts the composer on the tiled ground, not against a seam', () => {
+ const css = read('public/css/assistant.css');
+ // The tiles belong to the whole view so they run behind AND below the
+ // composer; previously they stopped at the transcript edge.
+ assert.match(css, /#assistant-chat-view:has\(\.assistant-messages:not\(:has\(\.assistant-msg\)\)\) \{\n\s*background-image:linear-gradient/);
+ assert.match(css, /\.assistant-messages:not\(:has\(\.assistant-msg\)\) \{ display:flex; flex-direction:column; justify-content:flex-end;/,
+ 'the empty state hugs the composer');
+ assert.match(css, /margin:0 auto 12vh; max-width:760px/, 'and the composer sits above centre');
+});
diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js
index 576212a0..c841bdad 100644
--- a/test/clinical-release-integration.test.js
+++ b/test/clinical-release-integration.test.js
@@ -85,7 +85,7 @@ test('native admin and assistant modules retain budget, table/source identity an
document.getElementById('btn-save-assistant-config').click();
await tick();
assert.equal(limit, 2000);
- assert.equal(calls.filter(call => call.options.method === 'PUT').length, 8, 'one native admin initializer; prompts are not generic setting saves');
+ assert.equal(calls.filter(call => call.options.method === 'PUT').length, 9, 'one native admin initializer; prompts are not generic setting saves');
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is an admin-settable override');
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
await tick(); await tick(); await tick();
diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js
index db9bcdc6..a36915a7 100644
--- a/test/frontend-prompt-env.test.js
+++ b/test/frontend-prompt-env.test.js
@@ -328,7 +328,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
['clinical_assistant.conversation_chars', ''],
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'],
['clinical_assistant.translate_provider', 'libretranslate'],
- ['clinical_assistant.citations_enabled', 'true'],
+ ['clinical_assistant.show_sources', 'true'], ['clinical_assistant.preview_enabled', 'false'],
['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', '']
]);
});