From adcea2a0ca9efc1cebc845681b54b87e98fe9f2b Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 13:46:19 +0200 Subject: [PATCH] fix: image models can be added and offered; solid phone top bar on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image models - The Clinical Assistant "Image models" list waited on an old #assistant-image-model dropdown that no longer exists, so discovery never reached it and only four hard-coded fallbacks appeared — with no way to add any of the gateway's 50 image models. - Image Generation search rows now have + Add / Added. Added models are saved as clinical_assistant.image_model_roster (validated server-side: up to 100 ids) and appear in the Clinical Assistant list at once; ticking one there offers it to users. Anything already allowed or configured stays listed. Unsaved ticks survive an add. - The roster notification is guarded, so it can never fail the settings load. Phone top bar - The page is drawn under the status bar (viewport-fit=cover) and its theme colour was the removed header's blue, so on an iPhone content showed scrolling at the top of the screen. The row is now a real fixed element that extends behind the status bar (env(safe-area-inset-top), 0 in a normal tab), the menu button, sources pill and drawers clear it, and the theme colour is white. Verified in Chromium: + Add -> saved roster -> listed unticked; tick kept; remove works. Top bar is the only thing in the top 48px on all 22 pages; phone menu positions unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS --- public/components/admin.html | 4 +- public/css/assistant.css | 4 +- public/css/styles.css | 15 +++++-- public/index.html | 3 +- public/js/admin.js | 47 +++++++++++++++++++- public/js/admin/clinicalAssistant.js | 36 +++++++++++---- public/manifest.json | 2 +- src/routes/adminConfig.js | 7 +++ test/admin-clinical-assistant-wiring.test.js | 23 ++++++++++ test/assistant-mobile.test.js | 19 +++++++- 10 files changed, 140 insertions(+), 20 deletions(-) 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 = '

Found ' + data.count + ' image model' + (data.count === 1 ? '' : 's') + '

' + items.map(function(m) { return '
' + - '' + + '' + '' + esc(m.name || m.id) + '' + '' + esc(m.source || '') + '' + + imageAddButton(m.id) + '
'; }).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, /