diff --git a/docs/clinical-assistant.md b/docs/clinical-assistant.md index 9d507b1c..48d44914 100644 --- a/docs/clinical-assistant.md +++ b/docs/clinical-assistant.md @@ -95,10 +95,11 @@ Every message offers Translate with a target-language picker. Translation is the Important settings include: -All are stored in `settings` and edited under Admin → Clinical Assistant / -Learning, except the image roster, which is written by the Image Generation -card. Every one is read through `getSetting`, so an unset key falls back to the -default in the right-hand column. +All are stored in `settings`. The chat model and the two allowed lists are +edited under Admin → Models → Availability; the image roster is written by +**+ Add** under Admin → Models → Discover & test; the rest under Admin → +Clinical Assistant. Every one is read through `getSetting`, so an unset key +falls back to the default in the right-hand column. | Setting | Purpose | |---|---| @@ -107,7 +108,7 @@ default in the right-hand column. | `clinical_assistant.fallback_image_model` | Single retry target when the image model fails | | `clinical_assistant.allowed_models` | Comma-separated chat models a user may pick. Empty means no choice: the configured model is used. A non-empty list always includes the configured model; anything else is rejected with 400 `model_not_allowed` | | `clinical_assistant.allowed_image_models` | The same, for image models | -| `clinical_assistant.image_model_roster` | Image models an admin added from Admin → Image Generation (**+ Add**). This is the pool the Image models tick-list offers; it is not itself an allowlist. Validated as up to 100 ids | +| `clinical_assistant.image_model_roster` | Image models an admin added under Admin → Models → Discover & test (**+ Add**), listed on the Roster card. This is the pool the Image models tick-list offers; it is not itself an allowlist. Validated as up to 100 ids | | `clinical_assistant.search_limit` | Number of MCP results requested | | `clinical_assistant.context_chars` | Context characters requested from MCP | | `clinical_assistant.conversation_chars` | Input budget in UTF-16 code units. Empty means use `CLINICAL_ASSISTANT_CONVERSATION_CHARS`; a value must be 1000-1000000 | @@ -184,10 +185,11 @@ delegated `change` listener as before, under an account-scoped storage key. The whole control is hidden unless the allowlist offers more than one model. For an image model to reach a user, an admin does two things: **+ Add** it under -Admin → Image Generation (which puts it in `image_model_roster`), then tick it -in the Clinical Assistant's Image models list (which puts it in -`allowed_image_models`). Discovery lists what the gateway advertises with mode -`image_generation`; it never adds anything on its own. +Admin → Models → Discover & test with the Image kind selected (which puts it in +`image_model_roster`), then tick it in the Image models list under Admin → +Models → Availability (which puts it in `allowed_image_models`). Discovery +lists what the gateway advertises with mode `image_generation`; it never adds +anything on its own. ## Testing Priorities diff --git a/docs/my-resources.md b/docs/my-resources.md index a9a31eb2..4f1854ea 100644 --- a/docs/my-resources.md +++ b/docs/my-resources.md @@ -296,7 +296,7 @@ and a bare `src` would not carry the session on a mobile client. Every workflow tries its configured model first, then each fallback in order, stopping at the first that produces an image. Primary plus two, capped — each -hop is a paid request. Set in **Admin → Image models**. +hop is a paid request. Set in **Admin → Models → Availability → Image generation, per workflow**. A fallback is only tried where another model has a real chance: @@ -342,7 +342,7 @@ renders without the figure when it has gone. ## Slide review -Off unless an administrator names a model, in **Admin → Slide review**. +Off unless an administrator names a model, in **Admin → Models → Availability → Slide review**. The model that writes a deck never sees it, so overflow, a figure on the wrong slide and a nine-item list that wants two columns are invisible to it. With a diff --git a/public/components/admin.html b/public/components/admin.html index 806b3d8e..9d6d3dab 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -10,17 +10,26 @@
The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.
+The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.
Configure OpenID Connect for SSO with Azure AD, Okta, Keycloak, PocketID, Google, etc.
Callback URL:
Configure OpenID Connect for SSO with Azure AD, Okta, Keycloak, PocketID, Google, etc.
Callback URL:
Environment variables take precedence over DB settings. Storing password in DB is convenient but consider using env vars for production security.
+Environment variables take precedence over DB settings. Storing password in DB is convenient but consider using env vars for production security.
+ 1. Discover & test a model the gateway offers and add it → + 2. Roster is everything added, with the default and on/off switches → + 3. Availability is which roster models each feature may use. +
- -Press Search gateway to list what it offers for this kind. The box filters by name.
+ + +Press Test on a model above or on the roster, or type an id the gateway does not advertise.
+Press Test on a model above, or type an id the gateway does not advertise. A picture is generated, so this costs one request.
+Used wherever nobody chose a model explicitly. Only enabled models can be the default.
Loading...
Click "Search API" to query your configured provider for all available models. Use the search box to filter results.
-Added with + Add under Discover & test. Under Availability, tick one to offer it in chat and pick one per workflow.
+Loading...
Speech, transcription and embedding models are single defaults rather than a roster: choose them with Set under Discover & test.
- Nothing on this page saves by itself. Each Save button applies only the - settings above it, up to the previous Save. This card has two: image settings have their - own, and everything else is saved by Save model & retrieval settings - at the bottom. -
+ +Only models on the roster appear here. If one is missing, add it under Discover & test first. One Save availability at the bottom applies everything in this card.
+ +Models users may select in the Clinical Assistant. Leave a list empty to keep only the configured model.
-To make a new model appear here, add it from the gateway: chat models under AI Model Management above, image models under Image Generation below — search there, press + Add, and it shows up in this list.
-Leave every box empty to offer only the chat model above.
Leave every box empty to offer only the workflow's own image model.
+ The model that writes a deck never sees it, so it cannot tell that a slide + overflowed or that a long list wants two columns. With a reviewer chosen, each + generated deck is rendered, looked at, and its layout corrected once. Wording and + figures are never changed — a review that alters them is discarded. +
++ Needs to be able to see images. One image per slide is sent on every + presentation generated, so this costs money each time — a cheap capable + vision model is the right choice here, not the best one available. +
+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.
+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.
UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.
+UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.
This sends text outside the building. When a resource author turns it on, the model may send a short search query to the provider below. Only the query @@ -436,7 +534,7 @@
Only used when the provider is SearXNG. Its /search?format=json endpoint must be enabled.
Only used when the provider is SearXNG. Its /search?format=json endpoint must be enabled.
+
A separate source from the web providers above, and a separate tick box for authors. It returns structured records — title, journal, year, PMID — so a reference can be exact rather than reconstructed from a page title. Queries @@ -485,7 +583,7 @@
- The model that writes a deck never sees it, so it cannot tell that a slide - overflowed or that a long list wants two columns. With a reviewer chosen, each - generated deck is rendered, looked at, and its layout corrected once. Wording and - figures are never changed — a review that alters them is discarded. -
-- Needs to be able to see images. One image per slide is sent on every - presentation generated, so this costs money each time — a cheap capable - vision model is the right choice here, not the best one available. -
-Answers where the assistant wrote a citation number that no returned source matched. Those markers are never turned into links, so a reader sees plain text — this is where they are recorded. Consistent entries usually mean retrieval is returning fewer sources than the answer assumes.
+Answers where the assistant wrote a citation number that no returned source matched. Those markers are never turned into links, so a reader sees plain text — this is where they are recorded. Consistent entries usually mean retrieval is returning fewer sources than the answer assumes.
- -Click Search to query available voices from your configured TTS provider.
-Click Search to query available STT models from your configured provider.
-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.
-Press Test on a model above, or type an id the gateway does not advertise.
-Corpus (MCP) embeddings are configured on the indexing service, not here.
- - -Click Search to query available embedding models from your configured provider.
-Reset all announcement, feature flag, and email settings back to factory defaults. SMTP configuration and custom models are preserved.
LiteLLM mode: No built-in models. ' + - 'Use Search API below to discover models from your proxy, then add them.
' + + 'Use Discover & test above to find models on your gateway, then add them.' + 'Querying provider API...
'; - if (hint) hint.style.display = 'none'; + if (hint) hint.hidden = true; fetch('/api/admin/config/models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) @@ -1010,7 +1017,7 @@ initImageSettings(); return; } - container.innerHTML = 'Found ' + data.count + ' models. Click + to add to your model list.
' + + container.innerHTML = 'Found ' + data.count + ' chat models. Press + to add one to the roster.
' + data.models.slice(0, 100).map(function(m) { return 'Querying provider...
'; - if (hint) hint.style.display = 'none'; + if (hint) hint.hidden = true; fetch('/api/admin/config/tts/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) @@ -1388,14 +1465,13 @@ initImageSettings(); if (adminTabActive()) loadSTTConfig(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-stt-record')) toggleRecording(); - if (e.target.closest('#btn-discover-stt')) discoverSTT(); if (e.target.closest('.admin-stt-set-btn')) { var btn = e.target.closest('.admin-stt-set-btn'); setSTTDefault(btn.dataset.id, btn); } }); - document.addEventListener('keydown', function(e) { - if (e.target.id === 'admin-stt-search' && e.key === 'Enter') { e.preventDefault(); discoverSTT(); } + document.addEventListener('admin-discover', function(e) { + if (e.detail && e.detail.kind === 'stt') discoverSTT(); }); const esc = adminEscapeHtml; @@ -1426,12 +1502,12 @@ initImageSettings(); } function discoverSTT() { - var search = (document.getElementById('admin-stt-search') || {}).value || ''; - var container = document.getElementById('admin-stt-discovered'); - var hint = document.getElementById('admin-stt-discover-hint'); + var search = (document.getElementById('admin-discover-search') || {}).value || ''; + var container = document.getElementById('admin-discover-results'); + var hint = document.getElementById('admin-discover-hint'); if (!container) return; container.innerHTML = 'Querying provider...
'; - if (hint) hint.style.display = 'none'; + if (hint) hint.hidden = true; fetch('/api/admin/config/stt/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) @@ -1879,17 +1955,17 @@ initImageSettings(); // ============================================================ // Unlike TTS and STT there is no single default to Set: an image model is // chosen per workflow, so discovery here ends in a Test, and the workflow -// pickers in the Clinical Assistant card consume the same discovery call. -// Nothing loads on tab entry: the workflow pickers already make the one -// discovery call opening Admin needs, so this card asks only when searched. +// pickers under Availability consume the same discovery call. Nothing loads +// on tab entry: the workflow pickers already make the one discovery call +// opening Admin needs, so this kind 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); + // + Add puts a model on the image roster (clinical_assistant.image_model_roster). + // The Roster card lists it with a Remove; ticking it under Availability + // offers it to users. + document.addEventListener('assistant-image-roster', function() { syncImageRows(); renderImageRoster(); }); document.addEventListener('click', function(e) { - var add = e.target.closest('.admin-image-add-btn'); + var add = e.target.closest('.admin-image-add-btn, .admin-image-remove-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'); if (pick) { @@ -1898,20 +1974,22 @@ initImageSettings(); testImageModel(pick.dataset.id, pick); } }); + document.addEventListener('admin-discover', function(e) { + if (e.detail && e.detail.kind === 'image') discoverImageModels(); + }); document.addEventListener('keydown', function(e) { - if (e.target.id === 'admin-image-search' && e.key === 'Enter') { e.preventDefault(); discoverImageModels(); } if (e.target.id === 'admin-image-test-model' && e.key === 'Enter') { e.preventDefault(); testImageModel(e.target.value || ''); } }); const esc = adminEscapeHtml; function discoverImageModels() { - var search = (document.getElementById('admin-image-search') || {}).value || ''; - var container = document.getElementById('admin-image-discovered'); - var hint = document.getElementById('admin-image-discover-hint'); + var search = (document.getElementById('admin-discover-search') || {}).value || ''; + var container = document.getElementById('admin-discover-results'); + var hint = document.getElementById('admin-discover-hint'); if (!container) return; container.innerHTML = 'Querying provider...
'; - if (hint) hint.style.display = 'none'; + if (hint) hint.hidden = true; fetch('/api/admin/config/image-models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) @@ -1949,17 +2027,50 @@ initImageSettings(); 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'); + var container = document.getElementById('admin-discover-results'); if (!container) return; container.querySelectorAll('.admin-image-add-btn').forEach(function(btn) { btn.outerHTML = imageAddButton(btn.dataset.id); }); } + // The roster used to be visible only as ticks under the Clinical Assistant + // and as an "Added" badge on a discovery row that had to be searched for + // again. Everything on the roster is listed here, with the way off it. + function renderImageRoster() { + var container = document.getElementById('admin-image-roster'); + if (!container) return; + var roster = currentImageRoster(); + container.replaceChildren(); + if (!roster.length) { + var empty = document.createElement('p'); + empty.className = 'admin-note'; + empty.textContent = 'No image models added yet. Search for one under Discover & test and press + Add.'; + container.appendChild(empty); + return; + } + roster.forEach(function(id) { + var row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;'; + var name = document.createElement('span'); + name.style.cssText = 'flex:1;min-width:0;overflow-wrap:anywhere;'; + name.textContent = id; + var remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'btn-sm admin-image-remove-btn'; + remove.dataset.id = id; + remove.style.cssText = 'padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;'; + remove.textContent = 'Remove'; + row.appendChild(name); + row.appendChild(remove); + container.appendChild(row); + }); + } + function toggleImageRoster(id, btn) { if (!id) return; var roster = currentImageRoster(); @@ -1975,10 +2086,11 @@ initImageSettings(); 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'); + renderImageRoster(); + showToast(added ? id + ' removed from the image roster' + : id + ' added to the roster. Tick it under Availability to offer it to users.', 'success'); }) - .catch(function(err) { syncImageRows(); showToast(err.message || 'Request failed', 'error'); }); + .catch(function(err) { syncImageRows(); renderImageRoster(); showToast(err.message || 'Request failed', 'error'); }); } function testImageModel(modelId, btn) { @@ -2024,14 +2136,13 @@ initImageSettings(); if (adminTabActive()) loadEmbeddingConfig(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-test-embedding')) testEmbedding(); - if (e.target.closest('#btn-discover-embeddings')) discoverEmbeddings(); if (e.target.closest('.admin-embed-set-btn')) { var btn = e.target.closest('.admin-embed-set-btn'); setEmbeddingDefault(btn.dataset.id, btn.dataset.dims, btn); } }); - document.addEventListener('keydown', function(e) { - if (e.target.id === 'admin-embed-search' && e.key === 'Enter') { e.preventDefault(); discoverEmbeddings(); } + document.addEventListener('admin-discover', function(e) { + if (e.detail && e.detail.kind === 'embedding') discoverEmbeddings(); }); const esc = adminEscapeHtml; @@ -2073,12 +2184,12 @@ initImageSettings(); } function discoverEmbeddings() { - var search = (document.getElementById('admin-embed-search') || {}).value || ''; - var container = document.getElementById('admin-embed-discovered'); - var hint = document.getElementById('admin-embed-discover-hint'); + var search = (document.getElementById('admin-discover-search') || {}).value || ''; + var container = document.getElementById('admin-discover-results'); + var hint = document.getElementById('admin-discover-hint'); if (!container) return; container.innerHTML = 'Querying provider...
'; - if (hint) hint.style.display = 'none'; + if (hint) hint.hidden = true; fetch('/api/admin/config/embeddings/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index a70ea96f..d2e6c837 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -1,3 +1,5 @@ +import { saveImageSettings } from './imageSettings.js'; + function assistantBudgetMeta(budget, savedOverride) { var limit = budget && Number.isInteger(budget.limit) ? budget.limit.toLocaleString() : null; if (savedOverride) { @@ -14,9 +16,9 @@ function assistantBudgetMeta(budget, savedOverride) { export function initClinicalAssistantAdmin(adminEscapeHtml) { let configState = 'idle'; - // 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. + // Image models an admin added under Discover & test. 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 = []; @@ -65,10 +67,10 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { 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.'); + 'No image models on the roster yet. Add them under Discover & test, 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. + // Adding to or removing from the roster 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; @@ -93,36 +95,21 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { }); document.addEventListener('click', function(e) { - if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin(); - if (e.target.closest('#btn-retry-assistant-config')) loadAssistantAdmin(); + if (e.target.closest('#btn-save-assistant-config')) saveAssistantSettings(e.target.closest('#btn-save-assistant-config')); + if (e.target.closest('#btn-save-availability')) saveAvailability(); + // Both cards that need these settings carry the notice and its retry. + if (e.target.closest('.btn-retry-assistant-config')) loadAssistantAdmin(); if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel(); if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool(); if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool(); - if (e.target.closest('#btn-save-review-model')) saveReviewModel(); }); // ── Slide review ──────────────────────────────────────────────────── - // Its own control rather than part of Save model & retrieval settings: it is - // the one setting that spends money on every generation without a user having - // asked for anything, so turning it on should be a deliberate act. - function saveReviewModel() { - var select = document.getElementById('mr-review-model'); - var status = document.getElementById('mr-review-status'); - if (!select) return; - if (status) { status.textContent = 'Saving...'; status.style.color = 'var(--g500)'; } - putAssistantConfig('my_resources.review_model', select.value || '') - .then(function() { - if (!status) return; - status.textContent = select.value ? 'Decks will be reviewed by ' + select.value : 'Slide review is off'; - status.style.color = 'var(--green)'; - }) - .catch(function(err) { - if (!status) return; - status.textContent = err.message; - status.style.color = 'var(--red)'; - }); - } - + // Saved with the rest of Availability. It is the one setting that spends + // money on every generation without a user having asked for anything, which + // is why the picker defaults to Off and says so: turning it on is still a + // deliberate act, it just does not need a Save button of its own to be one. + // // Any chat model the gateway offers. Whether it can actually see an image is // not something the model list says, so the choice is the administrator's — // a deck reviewed by a text-only model is discarded rather than applied. @@ -149,21 +136,28 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { select.value = saved || ''; } + // One settings load feeds two cards — Availability and Clinical Assistant — + // so each has a Save, a status line and a failure notice of its own. function updateAssistantLoadState() { - var save = document.getElementById('btn-save-assistant-config'); - if (save) save.disabled = configState !== 'ready'; + ['btn-save-assistant-config', 'btn-save-availability'].forEach(function(id) { + var save = document.getElementById(id); + if (save) save.disabled = configState !== 'ready'; + }); // The retry lives inside an error message rather than sitting beside Save // looking like an ordinary control, which is how it read before. - var errorBox = document.getElementById('assistant-config-error'); - if (errorBox) errorBox.hidden = configState !== 'failed'; - var status = document.getElementById('assistant-admin-status'); - // The failure case is spelled out in the error box above, so repeating it - // here would only be noise. - if (status && configState !== 'ready') { - status.textContent = configState === 'failed' ? '' : 'Loading settings...'; - } else if (status && !/^Saved |^Not saved/.test(status.textContent)) { - status.textContent = 'Settings loaded. Unsaved changes are kept until you press Save.'; - } + document.querySelectorAll('.assistant-config-error').forEach(function(errorBox) { + errorBox.hidden = configState !== 'failed'; + }); + ['assistant-admin-status', 'assistant-availability-status'].forEach(function(id) { + var status = document.getElementById(id); + // The failure case is spelled out in the error box above, so repeating it + // here would only be noise. + if (status && configState !== 'ready') { + status.textContent = configState === 'failed' ? '' : 'Loading settings...'; + } else if (status && !/^Saved |^Not saved|^Not all/.test(status.textContent)) { + status.textContent = 'Settings loaded. Unsaved changes are kept until you press Save.'; + } + }); } function loadAssistantAdmin() { @@ -222,8 +216,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { 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. + // Only a nudge for the Roster list and the Add/Added buttons under + // Discover & test: 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); @@ -373,35 +367,74 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { }); } - function saveAssistantAdmin() { + // Each card saves exactly what it shows, so no card needs a note explaining + // what its Save covers. These were one button writing all eight keys from + // the bottom of a card that also held a second Save for the image settings. + function saveAssistantSettings(button) { if (configState !== 'ready') return; - var chat = document.getElementById('assistant-chat-model'); - if (!chat || chat.selectedIndex < 0) return; var status = document.getElementById('assistant-admin-status'); if (status) status.textContent = 'Saving...'; Promise.all([ - putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')), putAssistantConfig('clinical_assistant.conversation_chars', getValue('assistant-conversation-budget')), 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.show_sources', - (document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true'), - - putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')), - putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(',')) + (document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true') ]).then(function() { // A toast is gone in three seconds. Whether these settings are saved is // exactly the question an admin has when they come back to this page, so // the answer stays on the page. if (status) status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '.'; showToast('Assistant settings saved', 'success'); + closeCard(button); }).catch(function(err) { if (status) status.textContent = 'Not saved. Nothing was changed.'; showToast(err.message || 'Save failed', 'error'); }); } + // The chat model, the two allowed lists, the per-workflow image settings and + // the slide reviewer: everything on the Availability card, in one press. + function saveAvailability() { + if (configState !== 'ready') return; + var chat = document.getElementById('assistant-chat-model'); + if (!chat || chat.selectedIndex < 0) return; + var status = document.getElementById('assistant-availability-status'); + if (status) status.textContent = 'Saving...'; + Promise.all([ + putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')), + putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')), + putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(',')), + putAssistantConfig('my_resources.review_model', getValue('mr-review-model')), + saveImageSettings() + ]).then(function() { + var reviewer = getValue('mr-review-model'); + if (status) { + status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '. ' + + (reviewer ? 'Decks will be reviewed by ' + reviewer + '.' : 'Slide review is off.') + + ' New image jobs use these settings; existing jobs are unchanged.'; + } + showToast('Availability saved', 'success'); + }).catch(function(err) { + // Promise.all does not undo the writes that succeeded, so "nothing was + // changed" would be untrue here. + if (status) status.textContent = 'Not all of it was saved: ' + (err.message || 'Save failed'); + showToast(err.message || 'Save failed', 'error'); + }); + } + + // Save & Close: the card folds so the page reads as done. The summary keeps + // focus so a keyboard user is not dropped somewhere off screen. + function closeCard(button) { + var card = button && button.closest ? button.closest('details') : null; + if (!card) return; + card.open = false; + var summary = card.querySelector('summary'); + if (summary && typeof summary.focus === 'function') summary.focus(); + if (typeof card.scrollIntoView === 'function') card.scrollIntoView({ block: 'nearest' }); + } + function putAssistantConfig(key, value) { return fetch('/api/admin/config/' + encodeURIComponent(key), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: value }) diff --git a/public/js/admin/imageSettings.js b/public/js/admin/imageSettings.js index 4e8f091c..7e1b53b2 100644 --- a/public/js/admin/imageSettings.js +++ b/public/js/admin/imageSettings.js @@ -15,7 +15,7 @@ const WORKFLOWS = [ let loading = false; let loaded = false; let controls = {}; -let save; let status; let maxModels = 3; +let maxModels = 3; async function load() { const root = document.getElementById('workflow-image-settings'); @@ -32,15 +32,13 @@ async function load() { root.textContent = ''; const form = document.createElement('form'); - const heading = document.createElement('h4'); - heading.textContent = 'Image models'; const note = document.createElement('p'); note.style.cssText = 'margin:2px 0 10px;font-size:12px;color:var(--g500);'; note.textContent = 'Each workflow tries its model first, then its fallbacks in order, ' + 'stopping at the first that produces an image. A fallback is only tried when another ' + 'model has a real chance — a refusal, a rate limit, a provider fault, or a model the ' + 'gateway does not have. Bad credentials and a malformed request stop immediately.'; - form.append(heading, note); + form.append(note); controls = {}; WORKFLOWS.forEach(workflow => { @@ -72,33 +70,13 @@ async function load() { form.appendChild(group); }); - save = document.createElement('button'); - save.type = 'submit'; save.className = 'btn-sm btn-primary'; - save.textContent = 'Save image settings'; - status = document.createElement('p'); status.setAttribute('role', 'status'); - form.append(save, status); - - form.onsubmit = async e => { + // No Save of its own: this form sits inside the Availability card, whose + // one Save button saves it along with the rest. Enter in a field goes + // through that button too, so there is exactly one way to save the card. + form.onsubmit = e => { e.preventDefault(); - if (save.disabled) return; - save.disabled = true; - try { - for (const workflow of WORKFLOWS) { - const control = controls[workflow.key]; - const body = { - budget: Number(control.budget.value), - fallbacks: control.fallbacks.map(s => s.value).filter(Boolean) - }; - // Only the workflows that own a primary send one; My Resources would - // be overwriting a per-request choice with a form field. - if (workflow.key !== 'my_resources') body.model = control.primary.value; - await imageJson('/api/admin/image-settings/' + workflow.key, - { method: 'PUT', body: JSON.stringify(body) }); - } - status.textContent = 'Saved. New jobs use these settings; existing jobs are unchanged.'; - } catch (error) { - status.textContent = error.message + ' Nothing was saved.'; - } finally { save.disabled = false; } + const save = document.getElementById('btn-save-availability'); + if (save && !save.disabled) save.click(); }; root.append(form); loaded = true; @@ -106,6 +84,25 @@ async function load() { finally { loading = false; } } +// Called by the Availability card's Save. Rejects rather than quietly skipping +// when the form never rendered, so the card does not report "Saved" for +// settings that were not sent. +export async function saveImageSettings() { + if (!loaded) throw new Error('Image settings were not loaded, so they were not saved.'); + for (const workflow of WORKFLOWS) { + const control = controls[workflow.key]; + const body = { + budget: Number(control.budget.value), + fallbacks: control.fallbacks.map(s => s.value).filter(Boolean) + }; + // Only the workflows that own a primary send one; My Resources would + // be overwriting a per-request choice with a form field. + if (workflow.key !== 'my_resources') body.model = control.primary.value; + await imageJson('/api/admin/image-settings/' + workflow.key, + { method: 'PUT', body: JSON.stringify(body) }); + } +} + function row(labelText, control) { const wrap = document.createElement('div'); wrap.className = 'admin-row'; diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js index 893912da..636106c4 100644 --- a/test/admin-clinical-assistant-wiring.test.js +++ b/test/admin-clinical-assistant-wiring.test.js @@ -79,15 +79,29 @@ 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(); - // Eight, not nine: the signed-out preview is a feature flag and is saved by - // the Feature Flags card now, not by this button. - assert.equal(writes().length, 8); + // Each card saves exactly what it shows. Save & Close on the Clinical + // Assistant card writes the five retrieval/citation/translation/budget keys; + // the chat model and the two allowed lists belong to the Availability card + // and are written by its own Save below. The signed-out preview is a feature + // flag saved by the Feature Flags card, not by either. + assert.equal(writes().length, 5); assert.equal(writes().filter(c => /preview/.test(c.url)).length, 0, 'this button no longer writes the preview flag'); 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.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.show_sources', 'clinical_assistant.translate_provider' + 'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.show_sources', 'clinical_assistant.translate_provider' ]); assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success')); + assert.equal(save.closest('details').open, false, 'Save & Close folds the card once saved'); + + const before = writes().length; + document.getElementById('btn-save-availability').click(); await tick(); await tick(); + const availability = writes().slice(before).map(c => c.url.split('/').pop()); + // The per-workflow image settings are saved by the same button; that is + // covered where the image form can render (frontend-prompt-env). + for (const key of ['clinical_assistant.chat_model', 'clinical_assistant.allowed_models', + 'clinical_assistant.allowed_image_models', 'my_resources.review_model']) { + assert.ok(availability.includes(key), 'Save availability writes ' + key); + } document.getElementById('btn-test-assistant-chat-model').click(); await tick(); assert.deepEqual(JSON.parse(calls.find(c => c.url === '/api/admin/config/models/test').options.body), { modelId: 'chat' }); @@ -151,23 +165,33 @@ test('model availability comes from discovery, never hand-typed', () => { // Image models had a discovery ENDPOINT but no UI, so the only way to reach a -// newly added gateway model was to already know its id. -test('image model discovery sits beside TTS and STT, and ends in a test', () => { +// newly added gateway model was to already know its id. Discovery now has one +// card for every kind of model, with a kind switch, so image discovery is a +// kind rather than a card of its own. +test('image model discovery is a kind in the shared Discover & test card, and ends in a test', () => { const fs = require('node:fs'); const path = require('node:path'); const root = path.join(__dirname, '..'); const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'); const js = fs.readFileSync(path.join(root, 'public/js/admin.js'), 'utf8'); - // Same shape as the two discovery cards it now sits between. - ['admin-image-search', 'btn-discover-image', 'admin-image-discovered', 'admin-image-discover-hint'] + // One search box, one Search button, one result list and one hint for every kind. + ['admin-discover-search', 'btn-discover', 'admin-discover-results', 'admin-discover-hint'] .forEach(id => assert.ok(html.includes('id="' + id + '"'), 'admin.html has #' + id)); - assert.ok(html.indexOf('id="admin-image-search"') > html.indexOf('id="admin-stt-search"'), - 'the image card follows the STT card'); - assert.ok(html.indexOf('id="admin-image-search"') < html.indexOf('id="admin-embed-search"'), - 'and precedes embeddings'); + for (const kind of ['chat', 'image', 'tts', 'stt', 'embedding']) { + assert.ok(html.includes('id="admin-discover-kind-' + kind + '"'), 'a kind switch for ' + kind); + } + assert.equal((html.match(/id="admin-discover-search"/g) || []).length, 1, 'exactly one search box'); + // No leftover per-kind search boxes from the five cards this replaced. + for (const id of ['admin-model-search', 'admin-image-search', 'admin-tts-search', 'admin-stt-search', 'admin-embed-search']) { + assert.doesNotMatch(html, new RegExp('id="' + id + '"'), 'no separate #' + id); + } - assert.match(js, /if \(e\.target\.closest\('#btn-discover-image'\)\) discoverImageModels\(\);/); + // The kind switch dispatches; each discovery loader answers for its own kind. + assert.match(js, /CustomEvent\('admin-discover'/); + for (const kind of ['chat', 'image', 'tts', 'stt', 'embedding']) { + assert.match(js, new RegExp("e\\.detail\\.kind === '" + kind + "'\\) discover\\w+\\(\\);"), kind + ' listens'); + } assert.match(js, /'\/api\/admin\/config\/image-models\/discover\?q=' \+ encodeURIComponent\(search\)/, 'it calls the endpoint that already existed'); // Unlike a voice there is no single default to Set: an image model belongs to @@ -179,7 +203,7 @@ test('image model discovery sits beside TTS and STT, and ends in a 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', () => { +test('image models are added under Discover & test, listed on the Roster, and offered under Availability', () => { const fs = require('node:fs'); const path = require('node:path'); const root = path.join(__dirname, '..'); @@ -190,6 +214,14 @@ test('image models are added from Image Generation and offered from the Clinical 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'); + // The roster is visible as a list of its own, with the way off it, rather + // than only as ticks and as an "Added" badge on a row that had to be + // searched for again. + const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'); + assert.match(html, /id="admin-image-roster"/); + assert.match(admin, /function renderImageRoster\(\)/); + assert.match(admin, /admin-image-remove-btn/, 'each roster row has Remove'); + assert.match(admin, /closest\('\.admin-image-add-btn, \.admin-image-remove-btn'\)/, 'Remove is the same toggle as Add'); assert.doesNotMatch(ca, /IMAGE_MODEL_FALLBACKS/, 'no hard-coded fallbacks'); assert.match(ca, /imageRosterSaved = parseAssistantList\(cfg\['clinical_assistant\.image_model_roster'\]\)/); diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js index 4ec8708e..3ea816d8 100644 --- a/test/clinical-release-integration.test.js +++ b/test/clinical-release-integration.test.js @@ -85,8 +85,10 @@ 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); - // Eight since the signed-out preview moved to the Feature Flags card. - assert.equal(calls.filter(call => call.options.method === 'PUT').length, 8, 'one native admin initializer; prompts are not generic setting saves'); + // Five: Save & Close writes the Clinical Assistant card's own settings. The + // chat model and allowed lists are saved by the Availability card, and the + // signed-out preview by the Feature Flags card. + assert.equal(calls.filter(call => call.options.method === 'PUT').length, 5, 'one native admin initializer; prompts are not generic setting saves'); assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is an admin-settable override'); document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } })); await tick(); await tick(); await tick(); diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index 19034b0d..4c0f2015 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -289,6 +289,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves await forceAssistantSave(ui); assert.equal(writes(ui).length, 0, 'failed configuration must never write defaults'); assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true); + assert.equal(ui.document.getElementById('btn-save-availability').disabled, true, 'both cards fed by this load are held'); // The failure is stated in an error box now rather than in a status line // beside Save, where a bare 'Retry loading settings' button read like an // ordinary control that was always there. @@ -332,15 +333,22 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 3, 'ready revisits neither reload nor add handlers'); setting(ui, 'search-limit').value = '19'; ui.document.getElementById('btn-save-assistant-config').click(); await tick(); + // Save & Close writes only what the Clinical Assistant card shows. The chat + // model and the allowed lists are the Availability card's, saved below. assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [ - ['clinical_assistant.chat_model', 'saved-chat'], // Saving an untouched form must NOT turn the environment value into a // stored override — empty is the "use the environment" signal. ['clinical_assistant.conversation_chars', ''], ['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'], ['clinical_assistant.translate_provider', 'libretranslate'], - ['clinical_assistant.show_sources', 'true'], - ['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', ''] + ['clinical_assistant.show_sources', 'true'] + ]); + const settingsWrites = writes(ui).length; + ui.document.getElementById('btn-save-availability').click(); await tick(); + assert.deepEqual(writes(ui).slice(settingsWrites).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [ + ['clinical_assistant.chat_model', 'saved-chat'], + ['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', ''], + ['my_resources.review_model', ''] ]); }); @@ -399,7 +407,10 @@ test('image-model dropdowns keep saved selections through discovery failures and const clinical = ui.document.querySelectorAll('#workflow-image-settings fieldset')[0]; assert.equal(clinical.querySelectorAll('select')[1].value, 'saved-backup', 'saved fallback kept'); - ui.document.querySelector('#workflow-image-settings form button[type="submit"]').click(); + // The image form has no Save of its own: the Availability card's one Save + // writes it along with the chat model, the allowed lists and the reviewer. + assert.equal(ui.document.querySelector('#workflow-image-settings button[type="submit"]'), null); + ui.document.getElementById('btn-save-availability').click(); // One await per workflow, so the queue needs draining more than once. for (let i = 0; i < 6; i++) await tick(); const puts = () => ui.calls.filter(c => c.options.method === 'PUT' && c.url.includes('/api/admin/image-settings/')); diff --git a/test/policy-flows.test.js b/test/policy-flows.test.js index e3878707..31fe2cf3 100644 --- a/test/policy-flows.test.js +++ b/test/policy-flows.test.js @@ -519,7 +519,8 @@ test('actual native admin script disables selected default, displays backend rep await waitFor(() => toasts.some(([message]) => message.startsWith('Default model set:'))); assert.equal(f.state.settings['models.default'], select.value); assert.equal(calls.filter(url => url === '/api/admin/config/models').length, 2, 'successful toggle refreshes models'); - document.getElementById('btn-discover-models').click(); + // One Search button for every kind; chat is the kind selected on open. + document.getElementById('btn-discover').click(); await waitFor(() => document.querySelector('.admin-add-discovered')); document.querySelector('.admin-add-discovered').click(); await waitFor(() => select.value === 'discovered');