feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier) The previous toggle branched the SYSTEM PROMPT, so the same question could get a different answer depending on a display setting — the bias this was meant to avoid. The prompt is now unconditional: buildSystemPrompt takes no display argument and is byte-identical either way. Hiding sources happens on the way out — the server omits them and strips the now-orphaned [n] markers from the copy it sends. The answer is generated, stored and exported with citations intact, so turning the setting back on restores them without re-asking anything. Renamed to clinical_assistant.show_sources; the old key is still honoured. Signed-out preview (admin opt-in, default off) A visitor may try the assistant; reaching for the workspace asks them to sign in. Deliberately narrow: - Reachable paths are an exact allow-list, not a pattern, so a new endpoint is private unless someone adds it on purpose. - A preview visitor gets no identity at all (id: null), so nothing can be owned, saved, billed or addressed to them. - The image tool is withheld rather than left to fail on a null owner, and no audit rows are written. - A caller presenting a token is authenticated normally, so preview can never downgrade a real session; if the setting cannot be read, authentication is required. - Actions needing an account are hidden rather than offered and refused. Composer The bar above the transcript is gone. Patient take home, Export PDF, Download transcript and Attach images moved into a + menu in the composer, and the model selector moved beside send — shown only when there is more than one model, as before. Both views now start at the same top edge, so switching modes cannot nudge the page up or down. On an empty transcript the tiled ground runs behind and below the composer, which floats on it above centre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
This commit is contained in:
parent
b9038eafcc
commit
db83255c58
17 changed files with 461 additions and 150 deletions
|
|
@ -328,13 +328,18 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Citations</strong>
|
||||
<strong class="admin-row-label">Sources</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="assistant-citations-enabled" checked>
|
||||
Show numbered citations and the Sources panel
|
||||
<input type="checkbox" id="assistant-show-sources" checked>
|
||||
Show the Sources panel and numbered citations
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Retrieval and grounding are unchanged when this is off — answers are still built only from retrieved sources, but the [1] markers and the Sources panel are hidden.</p>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="assistant-preview-enabled">
|
||||
Let signed-out visitors preview the assistant
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Preview is read-only: visitors can try the assistant, and anything else asks them to sign in or create an account.</p>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Display only. The AI prompt, retrieval and the stored answer are byte-for-byte identical either way, so turning this off cannot change what the model says. Citations are hidden, not removed — turning it back on restores them.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
<div class="assistant-drawer-backdrop" id="assistant-drawer-backdrop"></div>
|
||||
<aside class="assistant-history">
|
||||
<button id="btn-assistant-drawer-close" class="assistant-drawer-close" type="button" aria-label="Close saved chats"><i class="fas fa-xmark"></i></button>
|
||||
<p class="assistant-preview-note"><i class="fas fa-circle-info"></i> You are previewing. Sign in to save chats, export and generate images.</p>
|
||||
<button type="button" class="assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-table-columns"></i></button>
|
||||
<div class="assistant-mode-switch" role="tablist" aria-label="Assistant or workspace">
|
||||
<button type="button" role="tab" class="active" aria-selected="true" data-assistant-mode="assistant"><i class="fas fa-brain"></i> Assistant</button>
|
||||
|
|
@ -22,6 +23,11 @@
|
|||
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> <span>New chat</span><kbd class="assistant-kbd">Ctrl+Shift+O</kbd></button>
|
||||
<button id="btn-assistant-create-image" class="assistant-create-image" type="button" title="Create image" aria-label="Create image"><i class="fas fa-wand-magic-sparkles"></i></button>
|
||||
</div>
|
||||
<!-- Workspace mode replaces the saved chats here with the app's own
|
||||
menu, so the rail is never left empty. Same source as the cards. -->
|
||||
<nav class="assistant-rail-workspace" id="assistant-rail-workspace" aria-label="Workspace">
|
||||
<div id="assistant-workspace-links" class="assistant-rail-links"></div>
|
||||
</nav>
|
||||
<div class="card">
|
||||
<button type="button" class="card-header assistant-chats-header" id="btn-assistant-chats-toggle" aria-expanded="true" aria-controls="assistant-saved-chats">
|
||||
<h3><i class="fas fa-bookmark"></i> Saved Chats</h3>
|
||||
|
|
@ -41,18 +47,6 @@
|
|||
<div id="assistant-workspace-cards" class="assistant-workspace-cards"></div>
|
||||
</div>
|
||||
<div id="assistant-chat-view">
|
||||
<div class="assistant-toolbar">
|
||||
<div>
|
||||
<strong>Clinical Question</strong>
|
||||
<span id="assistant-model-label" class="model-tag">Admin model</span>
|
||||
</div>
|
||||
<div class="assistant-toolbar-actions">
|
||||
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
|
||||
<button id="btn-assistant-download-chat" class="btn-sm btn-ghost" type="button"><i class="fas fa-download"></i> Download transcript</button>
|
||||
<button id="btn-assistant-takehome" class="btn-sm btn-ghost" type="button"><i class="fas fa-heart"></i> Patient take home</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="assistant-messages" class="assistant-messages" aria-live="polite">
|
||||
<div class="assistant-empty">
|
||||
<h3>Pediatric Clinical Assistant</h3>
|
||||
|
|
@ -67,21 +61,33 @@
|
|||
|
||||
<form id="assistant-form" class="assistant-composer">
|
||||
<label for="assistant-input">Clinical question</label>
|
||||
<div class="assistant-model-pill" id="assistant-model-pill">
|
||||
<select id="assistant-chat-model-select" class="assistant-model-control" aria-label="Chat model" hidden></select>
|
||||
</div>
|
||||
<textarea id="assistant-input" rows="1" placeholder="Ask a focused clinical question..." autocomplete="off" aria-describedby="assistant-context-warning"></textarea>
|
||||
<div id="assistant-context-warning" role="alert" hidden></div>
|
||||
<div id="assistant-attachments" class="assistant-attachments" aria-label="Attached images" hidden></div>
|
||||
<div class="assistant-composer-footer">
|
||||
<span id="assistant-autosave-state" class="assistant-autosave-state" aria-live="polite"></span>
|
||||
<div class="assistant-tools">
|
||||
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i></label>
|
||||
<!-- One + for everything that acts on the conversation, so the
|
||||
top of the view stays empty and both modes line up. -->
|
||||
<div class="assistant-plus">
|
||||
<button id="btn-assistant-plus" class="assistant-plus-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="assistant-plus-menu" title="Attach and export" aria-label="Attach and export"><i class="fas fa-plus"></i></button>
|
||||
<div id="assistant-plus-menu" class="assistant-plus-menu" role="menu" hidden>
|
||||
<label class="assistant-plus-item" for="assistant-attach-input" role="menuitem"><i class="fas fa-paperclip"></i> Attach images</label>
|
||||
<button id="btn-assistant-takehome" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-heart"></i> Patient take home</button>
|
||||
<button id="btn-assistant-export-pdf" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-file-pdf"></i> Export PDF</button>
|
||||
<button id="btn-assistant-download-chat" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-download"></i> Download transcript</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
|
||||
<span id="assistant-autosave-state" class="assistant-autosave-state" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="assistant-composer-right">
|
||||
<div class="assistant-model-pill" id="assistant-model-pill" hidden>
|
||||
<select id="assistant-chat-model-select" class="assistant-model-control" aria-label="Chat model" hidden></select>
|
||||
</div>
|
||||
<button id="btn-assistant-voice" class="assistant-voice-btn" type="button" title="Voice conversation"><i class="fas fa-headset"></i></button>
|
||||
<button id="btn-assistant-mic" class="assistant-mic" type="button" title="Dictate your question"><i class="fas fa-microphone"></i></button>
|
||||
<button id="btn-assistant-send" class="btn-send" type="submit"><i class="fas fa-arrow-up"></i></button>
|
||||
</div>
|
||||
<button id="btn-assistant-send" class="btn-send" type="submit"><i class="fas fa-arrow-up"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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(','))
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
|
|
|||
|
|
@ -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 = '<i class="' + escapeAttr(icon ? icon.className : 'fas fa-circle') + '"></i>' +
|
||||
var body = '<i class="' + escapeAttr(icon ? icon.className : 'fas fa-circle') + '"></i>' +
|
||||
'<span>' + escapeHtml(label ? label.textContent : name) + '</span>';
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, '')
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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"/);
|
||||
});
|
||||
|
|
|
|||
93
test/assistant-preview.test.js
Normal file
93
test/assistant-preview.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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('</form>'));
|
||||
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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', '']
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue