diff --git a/public/components/admin.html b/public/components/admin.html
index dc8923eb..74c5190d 100644
--- a/public/components/admin.html
+++ b/public/components/admin.html
@@ -296,7 +296,7 @@
Model availability
Models users may select in the Clinical Assistant. Leave a list empty to keep only the configured model.
-
This list is what the gateway already offers. To make a new model appear here, add it in AI Model Management above — search the gateway, press +, and it shows up in this list.
+
To make a new model appear here, add it from the gateway: chat models in AI Model Management above, image models in Image Generation below — search, press + Add, and it shows up in this list.
Chat models
@@ -462,7 +462,7 @@
-
Click Search to list the image models your gateway advertises. Unlike a voice, an image model has no single default: pick which workflow uses it under Clinical Assistant / Learning, and tick it there to offer it to users in chat.
+
Click Search to list the image models your gateway advertises. + Add puts a model in the Clinical Assistant's Image models list; tick it there to offer it to users in chat. Unlike a voice, an image model has no single default — each workflow picks its own under Clinical Assistant / Learning.
diff --git a/public/css/assistant.css b/public/css/assistant.css
index 35940968..d1339ce2 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -222,7 +222,7 @@
padding-bottom:calc(12px + env(safe-area-inset-bottom)); }
/* Slim toolbar: icon-only actions in one row */
/* 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:0; overflow:hidden; gap:0; border-radius:0; }
+ .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:env(safe-area-inset-top, 0px) 0 0; overflow:hidden; gap:0; border-radius:0; }
.assistant-history .menu-head { padding:14px 14px 8px; }
.assistant-rail-actions { padding:0 8px; }
.assistant-rail-workspace { padding:2px 8px 8px; }
@@ -560,7 +560,7 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
@media (max-width:640px) {
/* It sits in the top bar's row, beside the menu button, so it never covers
the conversation — floating above the composer, it did. */
- .assistant-sources-fab { position:fixed; right:8px; top:8px; z-index:81;
+ .assistant-sources-fab { position:fixed; right:calc(8px + env(safe-area-inset-right, 0px)); top:calc(8px + env(safe-area-inset-top, 0px)); z-index:81;
display:inline-flex; align-items:center; gap:6px; height:32px; padding:0 12px; border:1px solid var(--g200);
border-radius:999px; background:white; color:var(--blue); font-family:inherit; font-size:12.5px;
font-weight:600; cursor:pointer; }
diff --git a/public/css/styles.css b/public/css/styles.css
index 64a38ba9..7f9af4e2 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -59,9 +59,10 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.menu-icon-btn:hover { background:var(--g100); color:var(--g800); }
.assistant-history .menu-head { padding:0 0 8px; }
.btn-menu-toggle { display:none; }
+.mobile-topbar { display:none; }
:root { --mobile-bar:0px; }
@media(max-width:768px){
- .btn-menu-toggle { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:6px; left:8px; z-index:81; width:36px; height:36px; border:none; border-radius:10px; background:none; color:var(--g700); font-size:16px; }
+ .btn-menu-toggle { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:calc(6px + env(safe-area-inset-top, 0px)); left:calc(8px + env(safe-area-inset-left, 0px)); z-index:81; width:36px; height:36px; border:none; border-radius:10px; background:none; color:var(--g700); font-size:16px; }
.btn-menu-toggle:hover { background:var(--g100); }
}
@@ -1412,7 +1413,7 @@ body.assistant-preview .account-card { display:none; }
assistant used to hide it and add a second drawer of its own, which is why two
hamburgers appeared and neither did anything useful. */
@media(max-width:768px){
- .sidebar{ width:min(85vw,320px); left:calc(-1 * min(85vw,320px)); padding:0; }
+ .sidebar{ width:min(85vw,320px); left:calc(-1 * min(85vw,320px)); padding:env(safe-area-inset-top, 0px) 0 0; }
.sidebar.open{ left:0; }
/* Close and search lead, as in the reference. */
.menu-head{ padding:14px 14px 8px; gap:10px; }
@@ -1441,8 +1442,14 @@ body.assistant-preview .account-card { display:none; }
header. The strip is fixed; the app starts below it. It belongs to
#main-app, so the sign-in screen never shows it. z-index stays under every
modal (the image preview is 99) and over page content (composer 60). */
- :root{ --mobile-bar:48px; }
- #main-app::before{ content:''; position:fixed; top:0; left:0; right:0; height:var(--mobile-bar);
+ /* The row also covers the status-bar area whenever the page is drawn under
+ it (viewport-fit=cover: the iOS home-screen app, notched phones), so no
+ content can scroll visibly above it. env() is 0 everywhere else, so the
+ row is exactly 48px in a normal browser tab. A real element rather than a
+ pseudo-element: iOS Safari looks for a fixed element at the top edge when
+ deciding what to paint behind the status bar. */
+ :root{ --mobile-bar:calc(48px + env(safe-area-inset-top, 0px)); }
+ .mobile-topbar{ display:block; position:fixed; top:0; left:0; right:0; height:var(--mobile-bar);
z-index:80; background:white; border-bottom:1px solid var(--g200); }
#main-app{ padding-top:var(--mobile-bar); }
.app-body, body.assistant-workspace .app-body{ min-height:calc(100dvh - var(--mobile-bar)); }
diff --git a/public/index.html b/public/index.html
index 12823b15..bc3c0613 100644
--- a/public/index.html
+++ b/public/index.html
@@ -24,7 +24,7 @@
-
+
@@ -153,6 +153,7 @@
floats — so a whole band of chrome earned its place back as content.
btn-settings and btn-logout stay as the canonical handlers other code
already calls; they are hidden, not duplicated. -->
+
diff --git a/public/js/admin.js b/public/js/admin.js
index e7f96bac..c3b5bb9a 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -1484,7 +1484,12 @@ initImageSettings();
// Nothing loads on tab entry: the workflow pickers already make the one
// discovery call opening Admin needs, so this card asks only when searched.
{
+ // + Add puts a model in the Clinical Assistant's Image models list
+ // (clinical_assistant.image_model_roster); ticking it there offers it to users.
+ document.addEventListener('assistant-image-roster', syncImageRows);
document.addEventListener('click', function(e) {
+ var add = e.target.closest('.admin-image-add-btn');
+ if (add) { toggleImageRoster(add.dataset.id, add); return; }
if (e.target.closest('#btn-discover-image')) discoverImageModels();
if (e.target.closest('#btn-test-image-model')) testImageModel((document.getElementById('admin-image-test-model') || {}).value || '');
var pick = e.target.closest('.admin-image-test-btn');
@@ -1526,9 +1531,10 @@ initImageSettings();
container.innerHTML = '
';
}).join('');
})
@@ -1537,6 +1543,45 @@ initImageSettings();
});
}
+ function currentImageRoster() {
+ return Array.isArray(window._assistantImageRoster) ? window._assistantImageRoster : [];
+ }
+
+ function imageAddButton(id) {
+ var added = currentImageRoster().indexOf(id) !== -1;
+ return added
+ ? ''
+ : '';
+ }
+
+ // Rows rendered before the roster loaded (or after it changed) catch up here.
+ function syncImageRows() {
+ var container = document.getElementById('admin-image-discovered');
+ if (!container) return;
+ container.querySelectorAll('.admin-image-add-btn').forEach(function(btn) { btn.outerHTML = imageAddButton(btn.dataset.id); });
+ }
+
+ function toggleImageRoster(id, btn) {
+ if (!id) return;
+ var roster = currentImageRoster();
+ var added = roster.indexOf(id) !== -1;
+ var next = added ? roster.filter(function(x) { return x !== id; }) : roster.concat([id]);
+ adminSetButtonText(btn, '...', true);
+ fetch('/api/admin/config/' + encodeURIComponent('clinical_assistant.image_model_roster'), {
+ method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: next.join(',') })
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (!data.success) throw new Error(data.error || 'Could not update the image model list');
+ window._assistantImageRoster = next;
+ document.dispatchEvent(new CustomEvent('assistant-image-roster-changed', { detail: { roster: next.slice() } }));
+ syncImageRows();
+ showToast(added ? id + ' removed from the Clinical Assistant list'
+ : id + ' added. Tick it under Clinical Assistant to offer it to users.', 'success');
+ })
+ .catch(function(err) { syncImageRows(); showToast(err.message || 'Request failed', 'error'); });
+ }
+
function testImageModel(modelId, btn) {
var id = String(modelId || '').trim();
var result = document.getElementById('admin-image-test-result');
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js
index 8608e940..2a5b3129 100644
--- a/public/js/admin/clinicalAssistant.js
+++ b/public/js/admin/clinicalAssistant.js
@@ -14,7 +14,10 @@ function assistantBudgetMeta(budget, savedOverride) {
export function initClinicalAssistantAdmin(adminEscapeHtml) {
let configState = 'idle';
- const IMAGE_MODEL_FALLBACKS = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'];
+ // Image models an admin added from the Image Generation card. The list below
+ // offers these, plus anything already allowed or configured so a saved choice
+ // never drops out of view.
+ let imageRosterSaved = [];
let savedChatAllowed = [];
let savedImageAllowed = [];
let chatRoster = [];
@@ -23,7 +26,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
function parseAssistantList(value) {
return String(value || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
- function renderAssistantCheckboxList(containerId, candidates, saved) {
+ function renderAssistantCheckboxList(containerId, candidates, saved, emptyText) {
var container = document.getElementById(containerId);
if (!container) return;
var ids = candidates.slice();
@@ -33,7 +36,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
if (!ids.length) {
var empty = document.createElement('p');
empty.style.cssText = 'margin:0;font-size:12px;color:var(--g500);';
- empty.textContent = 'No models available. Add or enable models first.';
+ empty.textContent = emptyText || 'No models available. Add or enable models first.';
container.appendChild(empty);
return;
}
@@ -56,10 +59,22 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
if (!container) return [];
return Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]:checked'), function(box) { return box.value; });
}
- function renderAssistantImageModelCheckboxes(models) {
- imageRoster = (Array.isArray(models) ? models : []).map(function(m) { return m.id; }).concat(IMAGE_MODEL_FALLBACKS, [window._assistantImageModelValue]);
- renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed);
+ // Built from the admin's roster, not from discovery: the gateway advertises
+ // dozens of image models, and this used to wait on a dropdown that no longer
+ // exists, so only four hard-coded fallbacks ever appeared.
+ function renderAssistantImageModelCheckboxes() {
+ imageRoster = imageRosterSaved.concat([window._assistantImageModelValue]);
+ renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed,
+ 'No image models added yet. Add them under Image Generation below, then tick them here.');
}
+ // Adding or removing in the Image Generation card updates this list at once,
+ // keeping any ticks made here that have not been saved yet.
+ document.addEventListener('assistant-image-roster-changed', function(e) {
+ imageRosterSaved = (e.detail && Array.isArray(e.detail.roster)) ? e.detail.roster.slice() : imageRosterSaved;
+ if (configState !== 'ready') return;
+ savedImageAllowed = checkedAssistantModels('assistant-allowed-image-models');
+ renderAssistantImageModelCheckboxes();
+ });
let imageModelsLoading = false;
@@ -143,9 +158,14 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
savedChatAllowed = parseAssistantList(cfg['clinical_assistant.allowed_models']);
savedImageAllowed = parseAssistantList(cfg['clinical_assistant.allowed_image_models']);
+ imageRosterSaved = parseAssistantList(cfg['clinical_assistant.image_model_roster']);
+ window._assistantImageRoster = imageRosterSaved.slice();
+ // Only a nudge for the Image Generation card's buttons: it must never be
+ // able to fail the settings load itself.
+ try { document.dispatchEvent(new CustomEvent('assistant-image-roster', { detail: { roster: imageRosterSaved.slice() } })); } catch (e) {}
chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']);
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
- renderAssistantImageModelCheckboxes([]);
+ renderAssistantImageModelCheckboxes();
renderAssistantImageModels([], window._assistantImageModelValue);
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
@@ -216,7 +236,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
.then(function(data) {
if (!data || data.success === false || !Array.isArray(data.models) ||
!data.models.every(function(m) { return m && typeof m.id === 'string' && m.id; })) throw new Error('Invalid image models');
- renderAssistantImageModelCheckboxes(data.models);
+ renderAssistantImageModelCheckboxes();
renderAssistantImageModels(data.models, sel.value);
})
.catch(function() {
diff --git a/public/manifest.json b/public/manifest.json
index 11fde5ba..062702ed 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -5,7 +5,7 @@
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
- "theme_color": "#2563eb",
+ "theme_color": "#ffffff",
"orientation": "any",
"categories": ["medical", "productivity"],
"prefer_related_applications": false,
diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js
index 62173167..19cb2eeb 100644
--- a/src/routes/adminConfig.js
+++ b/src/routes/adminConfig.js
@@ -866,6 +866,13 @@ router.put('/config/:key(*)', async function(req, res) {
if (key === 'clinical_assistant.show_sources' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: 'Show sources must be true or false' });
}
+ // The admin's image-model roster: comma-separated gateway model ids.
+ if (key === 'clinical_assistant.image_model_roster') {
+ var rosterIds = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
+ if (rosterIds.length > 100 || rosterIds.some(function(id) { return id.length > 200 || /[\s<>"'`]/.test(id); })) {
+ return res.status(400).json({ error: 'Image model list must be up to 100 model ids' });
+ }
+ }
if (key.startsWith('prompt.') || promptCatalog.find(key)) {
if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' });
return changePrompt(req, res, 'save', key);
diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js
index 175f4721..0bb41fff 100644
--- a/test/admin-clinical-assistant-wiring.test.js
+++ b/test/admin-clinical-assistant-wiring.test.js
@@ -163,3 +163,26 @@ test('image model discovery sits beside TTS and STT, and ends in a test', () =>
assert.doesNotMatch(js, /admin-image-set-btn/);
assert.match(js, /'\/api\/admin\/config\/image-models\/test'/);
});
+
+// The Image models list waited on a dropdown that no longer exists, so only four
+// hard-coded fallbacks appeared and there was no way to add a gateway model.
+test('image models are added from Image Generation and offered from the Clinical Assistant list', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const root = path.join(__dirname, '..');
+ const admin = fs.readFileSync(path.join(root, 'public/js/admin.js'), 'utf8');
+ const ca = fs.readFileSync(path.join(root, 'public/js/admin/clinicalAssistant.js'), 'utf8');
+ const server = fs.readFileSync(path.join(root, 'src/routes/adminConfig.js'), 'utf8');
+
+ assert.match(admin, /admin-image-add-btn/, 'each discovered model has + Add');
+ assert.match(admin, /'\/api\/admin\/config\/' \+ encodeURIComponent\('clinical_assistant\.image_model_roster'\)/);
+ assert.match(admin, /new CustomEvent\('assistant-image-roster-changed'/, 'and the list updates at once');
+
+ assert.doesNotMatch(ca, /IMAGE_MODEL_FALLBACKS/, 'no hard-coded fallbacks');
+ assert.match(ca, /imageRosterSaved = parseAssistantList\(cfg\['clinical_assistant\.image_model_roster'\]\)/);
+ assert.match(ca, /imageRoster = imageRosterSaved\.concat\(\[window\._assistantImageModelValue\]\)/);
+ // A tick made but not saved survives an add from the other card.
+ assert.match(ca, /savedImageAllowed = checkedAssistantModels\('assistant-allowed-image-models'\);\s*\n\s*renderAssistantImageModelCheckboxes\(\);/);
+
+ assert.match(server, /key === 'clinical_assistant\.image_model_roster'/, 'the server validates the roster');
+});
diff --git a/test/assistant-mobile.test.js b/test/assistant-mobile.test.js
index 0771576e..7f130ec7 100644
--- a/test/assistant-mobile.test.js
+++ b/test/assistant-mobile.test.js
@@ -136,7 +136,7 @@ test('on a phone the menu follows the view: chat history in the assistant', () =
const css = read('public/css/assistant.css');
const mobile = css.slice(css.indexOf('@media (max-width: 640px)'));
// The name card is pinned at the foot of both menus: only the list scrolls.
- assert.match(mobile, /\.assistant-history \{ position:fixed;[^}]*padding:0; overflow:hidden;/, 'the drawer itself does not scroll');
+ assert.match(mobile, /\.assistant-history \{ position:fixed;[^}]*padding:env\(safe-area-inset-top, 0px\) 0 0; overflow:hidden;/, 'the drawer itself does not scroll, and clears the status bar');
assert.match(mobile, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto;/, 'the chat list does');
assert.match(mobile, /\.assistant-drawer-close \{ display:none !important; \}/, 'one close control, the « in the head row');
});
@@ -152,3 +152,20 @@ test('source cards fit the phone: long names wrap, nothing scrolls sideways or i
assert.match(mobile, /\.assistant-source-excerpt \{ max-height:none; overflow:visible; \}/, 'no scroll box inside the scrolling list');
assert.match(mobile, /\.assistant-sources \{[^}]*overflow-x:hidden; touch-action:pan-y;/, 'and no sideways pan');
});
+
+test('the phone top bar is solid behind the status bar too', () => {
+ const index = read('public/index.html');
+ const css = read('public/css/styles.css');
+ // Scrolling an admin page on an iPhone showed text flowing at the top of the
+ // screen: the page is drawn under the status bar (viewport-fit=cover) and the
+ // theme colour was the removed header's blue.
+ assert.match(index, /viewport-fit=cover/);
+ assert.match(index, /
<\/div>/, 'a real element, which iOS looks for at the top edge');
+ assert.match(index, //);
+ assert.match(read('public/manifest.json'), /"theme_color": "#ffffff"/);
+ assert.doesNotMatch(css, /#main-app::before/);
+ assert.match(css, /:root\{ --mobile-bar:calc\(48px \+ env\(safe-area-inset-top, 0px\)\); \}/, 'the row reaches up behind the status bar');
+ assert.match(css, /\.btn-menu-toggle \{[^}]*top:calc\(6px \+ env\(safe-area-inset-top, 0px\)\)/, 'and the button sits below it');
+ assert.match(css, /\.sidebar\{ width:min\(85vw,320px\); left:calc\(-1 \* min\(85vw,320px\)\); padding:env\(safe-area-inset-top, 0px\) 0 0; \}/);
+ assert.match(read('public/css/assistant.css'), /\.assistant-sources-fab \{[^}]*top:calc\(8px \+ env\(safe-area-inset-top, 0px\)\)/);
+});