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 @@
API Calls Today
+ + + +

Accounts

+ -
-

Registration

-
+
+

Registration

+
Loading... - +
@@ -34,7 +43,7 @@
- +
-

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.

@@ -51,18 +60,19 @@
-
-
+ - -
-
-

Users

+ +
+

Users

+
- +
-
+
@@ -78,166 +88,106 @@
-
- - -
-

Announcement Banner

-
-
- - - -
- -
- -
-
-
- - -
-

Feature Flags

-
-
- - -
-
- - -
-
- - -
-
-
+
-
-

Single Sign-On (OIDC)

-
-

Configure OpenID Connect for SSO with Azure AD, Okta, Keycloak, PocketID, Google, etc.
Callback URL:

+
+

Single Sign-On (OIDC)

+
+

Configure OpenID Connect for SSO with Azure AD, Okta, Keycloak, PocketID, Google, etc.
Callback URL:

- +
- +
- +
- +
- +
- +
-
- - +
+ +
-
+
-
-

Email Templates

-
- +
+

Email Templates

+
- +
- +
- +
- - + +
-
- - -
-
-

AI Scribe

-
-
-
Loading Scribe prompts...
-
-
+ -
-
+
+

SMTP Email Server

- Loading... -
-
+ Loading... + +
- +
- +
- +
- +
- +
- + - - - - - - - -
-
-
+ +

Models

+

+ 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. +

- -
-
-

AI Model Management

- Loading... -
-
- - -
- - - + +
+ +

Discover & test

+ Search the gateway, try a model, add it to the roster +
+
+ +
+ + + + +
- + +
+ Loading... + Chat models write notes, answer in the Clinical Assistant and draft resources. + Add puts one on the roster. +
+ + + + + +
+ + +
+
+

Press Search gateway to list what it offers for this kind. The box filters by name.

+ + +
+
Test
+
+

Press Test on a model above or on the roster, or type an id the gateway does not advertise.

+
+ + +
+
+
+ + + + +
+
+
+ + +
+ +

Roster

+ What has been added. Availability draws from this list. +
+
+

Chat models

+
+ + + +
+

Used wherever nobody chose a model explicitly. Only enabled models can be the default.

- +
Built-in models (untick to hide one from users)

Loading...

+
- -
- -
- - -
-
-
-

Click "Search API" to query your configured provider for all available models. Use the search box to filter results.

-
- - - -
+

Image models

+

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.

-
+
- -
-
-

Clinical Assistant / Learning

- Model, retrieval and prompt settings -
-
-

- 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. -

+ +
+ +

Availability

+ Which roster models each feature may use +
+
+

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.

+ +

Clinical Assistant

-
+
- Model availability -
-

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.

-
-
Chat models
-
-
-
-
Image models
-
-
+ Chat models users may pick +
+
+

Leave every box empty to offer only the chat model above.

-
-
-
Starter prompt pool
-
Checking prompt pool...
-
- -
- - +
+ Image models users may pick +
+
+

Leave every box empty to offer only the workflow's own image model.

+ +

Image generation, per workflow

+
+ + +

Slide review (My Resources)

+

+ 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. +

+
+
+ + +
+ + +
+
+
+ + +

Assistant & prompts

+ + +
+ +

Clinical Assistant

+ Retrieval, citations, translation, budget and starter prompts +
+
@@ -378,7 +450,7 @@ Show the Sources panel and numbered citations -

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.

@@ -388,12 +460,52 @@
- Conversation input budget +
-

UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.

+

UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.

+
+
+
Starter prompt pool
+
Checking prompt pool...
+
+ +
+ + +
+
+ + +
+ + +
+
+
+ + +
+ +

Prompts

+ Each prompt saves on its own +
+
+
+

Scribe prompts

+
Loading Scribe prompts...
+

Clinical Assistant prompts

Loading clinical prompts...
@@ -402,30 +514,16 @@

Learning prompts

Loading Learning prompts...
-
- -
- - Applies everything in this card except the image settings above. - -
-
+ -
-
+
+

Search Sources

- Off by default -
-
+ Off by default + +

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 @@

- Enabled +
-

Not needed for SearXNG. Shown masked once saved.

+

Not needed for SearXNG. Shown masked once saved.

@@ -469,7 +567,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.

@@ -477,7 +575,7 @@
PubMed
-

+

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 @@

- Enabled +
-

+

Optional. PubMed works without one; a key raises the rate limit from 3 requests per second to 10, which matters for bulk work rather than for one person writing a resource. Create one at @@ -511,208 +609,111 @@

-

NCBI asks callers to identify themselves so they can contact you before blocking you. Optional but courteous.

+

NCBI asks callers to identify themselves so they can contact you before blocking you. Optional but courteous.

-
+
-
- - -
-
-

Slide review

- Off unless a model is chosen -
-
-

- 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. -

-
-
-
- - -
-
-
+ -
-
+
+

Citation Quality

- Loading... + Loading... +
+
+

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.

- -
-
+ - -
-
-

Text-to-Speech (TTS)

- Loading... -
-
-
Loading...
+ +

Site

- + +
+

Announcement Banner

+
+
+ + + +
+
- -
- - -
-
-

Click Search to query available voices from your configured TTS provider.

-
- - -
- -
- - -
- -
- - -
-

+
-
+ - -
-
-

Speech-to-Text (STT)

- Loading... -
-
-
Loading...
- - + +
+

Feature Flags

+
+
+ + +
+
+ + +
- -
- - -
-
-

Click Search to query available STT models from your configured provider.

-
- - -
- -
- - -
- + +
-
+ - -
-
-

Image Generation

- Gateway -
-
-
Image models come from the LiteLLM gateway. Search to list them, and test one before assigning it to a workflow.
- - -
- -
- - -
-
-

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.

-
- - -
-
+ +
+ +

Saved Encounters (Site-wide)

+ Auto-deleted after 7 days +
+
+
+ + +
-
- - -
-
-

Embedding Models — Learning Hub semantic search

- Loading... -
-
-
Loading...
-

Corpus (MCP) embeddings are configured on the indexing service, not here.

- - -
- -
- - -
-
-

Click Search to query available embedding models from your configured provider.

-
- - -
- -
- - -
-
-
-
-
+ -
-

Reset Settings

-
+
+

Reset Settings

+

Reset all announcement, feature flag, and email settings back to factory defaults. SMTP configuration and custom models are preserved.

- +
-
+ diff --git a/public/css/styles.css b/public/css/styles.css index 77523032..3580a73d 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1263,6 +1263,37 @@ textarea.full-input{resize:vertical;} font-family: inherit; } +/* Admin cards fold. The chevron is drawn with borders rather than an icon + font so it cannot go missing when the font does. */ +#admin-tab details.card > summary.card-header { cursor:pointer; list-style:none; gap:10px; } +#admin-tab details.card > summary.card-header::-webkit-details-marker { display:none; } +#admin-tab details.card > summary.card-header::after { + content:''; flex:none; width:8px; height:8px; margin-left:4px; + border-right:2px solid var(--g500); border-bottom:2px solid var(--g500); + transform:rotate(45deg); transition:transform .15s ease; +} +#admin-tab details.card:not([open]) > summary.card-header::after { transform:rotate(-45deg); } +#admin-tab details.card > summary.card-header:focus-visible { outline:2px solid var(--blue); outline-offset:-2px; } +#admin-tab details.card > summary.card-header h3 { flex:none; } +.card-header-note { font-size:12px; color:var(--g500); margin-left:auto; text-align:right; } +.admin-card-body { padding:16px; display:flex; flex-direction:column; gap:12px; } +.admin-card-body > h4, .admin-card-body section > h4 { font-size:13px; font-weight:700; color:var(--g700); margin:0; } +.admin-card-body > h4:not(:first-child) { padding-top:12px; border-top:1px solid var(--g100); } +.admin-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; padding:10px 16px; border-bottom:1px solid var(--g100); } +.admin-section-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--g500); margin:24px 0 8px; } +.admin-section-intro { font-size:13px; color:var(--g600); line-height:1.6; margin:0 0 10px; } +.admin-subhead { font-size:12px; font-weight:600; color:var(--g600); margin-bottom:8px; } +.admin-note { font-size:12px; color:var(--g500); margin:0; line-height:1.5; } +.admin-badge { font-size:11px; padding:2px 8px; border-radius:10px; background:var(--g100); color:var(--g600); white-space:nowrap; } +.admin-search-row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; } +.admin-save-row { border-top:1px solid var(--g100); padding-top:12px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; } +.admin-kind-switch { display:flex; flex-wrap:wrap; gap:6px; } +.admin-discover-kind { display:inline-flex; align-items:center; gap:6px; padding:5px 12px; border:1px solid var(--g300); border-radius:999px; background:white; color:var(--g700); font-size:12px; font-weight:600; cursor:pointer; } +.admin-discover-kind:hover { background:var(--g100); } +.admin-discover-kind[aria-pressed="true"] { background:var(--blue); border-color:var(--blue); color:white; } +.admin-discover-kind:focus-visible { outline:2px solid var(--blue); outline-offset:2px; } +.admin-kind-status { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } + /* Open WebUI-style assistant workspace: saved chats replace the main menu */ /* 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, diff --git a/public/js/admin.js b/public/js/admin.js index 89d092da..e4309db1 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -878,20 +878,27 @@ initImageSettings(); if (adminTabActive()) loadAdminModels(); document.addEventListener('click', function(e) { - if (e.target.closest('#btn-discover-models')) discoverModels(); if (e.target.closest('#btn-save-default-model')) saveDefaultModel(); if (e.target.closest('#btn-clear-all-models')) clearAllModels(); + if (e.target.closest('#btn-test-chat-model')) { + testModel((document.getElementById('admin-chat-test-model') || {}).value || '', e.target.closest('#btn-test-chat-model')); + } if (e.target.closest('.admin-model-test-btn')) { var btn = e.target.closest('.admin-model-test-btn'); testModel(btn.dataset.mid, btn); } }); - // Allow Enter key to trigger search + // The Discover & test card has one search box for every kind of model; the + // kind switch decides which list is asked. See the discovery block below. + document.addEventListener('admin-discover', function(e) { + if (e.detail && e.detail.kind === 'chat') discoverModels(); + }); + document.addEventListener('keydown', function(e) { - if (e.target.id === 'admin-model-search' && e.key === 'Enter') { + if (e.target.id === 'admin-chat-test-model' && e.key === 'Enter') { e.preventDefault(); - discoverModels(); + testModel(e.target.value || '', document.getElementById('btn-test-chat-model')); } }); @@ -935,7 +942,7 @@ initImageSettings(); if (data.litellmHint) { container.innerHTML = '
' + '

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.

' + '
'; } else if (data.models.length === 0) { @@ -990,13 +997,13 @@ initImageSettings(); } function discoverModels() { - var search = (document.getElementById('admin-model-search') || {}).value || ''; - var container = document.getElementById('admin-discovered-models'); + 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 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 '
' + '' + @@ -1094,7 +1101,7 @@ initImageSettings(); return; } - container.innerHTML = '' + + container.innerHTML = '
Added from the gateway
' + custom.map(function(m) { return '
' + '' + esc(m.name) + ' (' + esc(m.id) + ')' + @@ -1129,7 +1136,7 @@ initImageSettings(); } function clearAllModels() { - showConfirm('Remove all added models? You will need to re-add them via Search API.', function() { + showConfirm('Remove all added models? You will need to find and add them again under Discover & test.', function() { Promise.all([ fetch('/api/admin/config/models/clear-all', { method: 'POST', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) @@ -1163,9 +1170,17 @@ initImageSettings(); } function testModel(modelId, btn) { - if (!modelId) return; + modelId = String(modelId || '').trim(); + // The result line outlives the toast, which is gone before an admin has + // scrolled back to read it. Rows on the roster share it with the box. + var result = document.getElementById('admin-chat-test-result'); + if (!modelId) { + if (result) result.textContent = 'Enter or pick a model id first.'; + return; + } var origText = btn ? btn.textContent : 'Test'; adminSetButtonText(btn, '...', true); + if (result) result.textContent = 'Testing ' + modelId + '...'; fetch('/api/admin/config/models/test', { method: 'POST', @@ -1176,19 +1191,82 @@ initImageSettings(); .then(function(data) { adminSetButtonText(btn, origText, false); if (data.success) { + if (result) result.textContent = modelId + ' works (' + (data.duration || 0) + ' ms): "' + (data.response || '?') + '"'; showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success'); } else { + if (result) result.textContent = modelId + ' failed: ' + (data.error || 'Unknown error'); showToast('Test failed: ' + (data.error || 'Unknown error'), 'error'); } }) .catch(function() { adminSetButtonText(btn, origText, false); + if (result) result.textContent = 'Request failed.'; showToast('Request failed', 'error'); }); } } +// ============================================================ +// ADMIN DISCOVERY — one search box for every kind of model +// ============================================================ +// Chat, image, speech, transcription and embedding models each have their own +// gateway list and their own row buttons, and they used to have a card each, +// scattered down the page. The kind switch keeps the five discovery calls as +// they are and only decides which one the Search button asks. The switch +// dispatches an 'admin-discover' event rather than calling the loaders, which +// live in their own blocks below. +{ + const DISCOVER_PLACEHOLDERS = { + chat: 'Filter by name (e.g. gemini, gpt, llama)', + image: 'Filter by name (e.g. dall-e, imagen, flux)', + tts: 'Filter voices or models (e.g. Journey, Neural, alloy)', + stt: 'Filter by name (e.g. gemini, whisper)', + embedding: 'Filter by name (e.g. embedding, vertex)' + }; + + function activeDiscoverKind() { + var pressed = document.querySelector('.admin-discover-kind[aria-pressed="true"]'); + return pressed ? pressed.dataset.kind : 'chat'; + } + + function selectDiscoverKind(kind) { + document.querySelectorAll('.admin-discover-kind').forEach(function(btn) { + btn.setAttribute('aria-pressed', btn.dataset.kind === kind ? 'true' : 'false'); + }); + document.querySelectorAll('.admin-kind-panel').forEach(function(panel) { + panel.hidden = panel.dataset.kind !== kind; + }); + // Results from one kind mean nothing under another: the row buttons would + // add an image model to the chat roster. + var results = document.getElementById('admin-discover-results'); + if (results) results.innerHTML = ''; + var hint = document.getElementById('admin-discover-hint'); + if (hint) hint.hidden = false; + var search = document.getElementById('admin-discover-search'); + if (search) search.placeholder = DISCOVER_PLACEHOLDERS[kind] || DISCOVER_PLACEHOLDERS.chat; + } + + function runDiscover() { + var search = document.getElementById('admin-discover-search'); + // Built by the document's own window: an event from any other realm is + // refused by dispatchEvent, and a refused Search does nothing visible. + var win = document.defaultView || window; + document.dispatchEvent(new win.CustomEvent('admin-discover', { + detail: { kind: activeDiscoverKind(), query: search ? search.value : '' } + })); + } + + document.addEventListener('click', function(e) { + var kindBtn = e.target.closest('.admin-discover-kind'); + if (kindBtn) selectDiscoverKind(kindBtn.dataset.kind); + if (e.target.closest('#btn-discover')) runDiscover(); + }); + document.addEventListener('keydown', function(e) { + if (e.target.id === 'admin-discover-search' && e.key === 'Enter') { e.preventDefault(); runDiscover(); } + }); +} + // ============================================================ // ADMIN TTS MANAGEMENT // ============================================================ @@ -1200,14 +1278,13 @@ initImageSettings(); if (adminTabActive()) loadTTSConfig(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-test-tts')) testTTS(); - if (e.target.closest('#btn-discover-tts')) discoverTTS(); if (e.target.closest('.admin-tts-set-btn')) { var btn = e.target.closest('.admin-tts-set-btn'); setTTSDefault(btn.dataset.id, btn.dataset.type, btn); } }); - document.addEventListener('keydown', function(e) { - if (e.target.id === 'admin-tts-search' && e.key === 'Enter') { e.preventDefault(); discoverTTS(); } + document.addEventListener('admin-discover', function(e) { + if (e.detail && e.detail.kind === 'tts') discoverTTS(); }); const esc = adminEscapeHtml; @@ -1254,12 +1331,12 @@ initImageSettings(); } function discoverTTS() { - var search = (document.getElementById('admin-tts-search') || {}).value || ''; - var container = document.getElementById('admin-tts-discovered'); - var hint = document.getElementById('admin-tts-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/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');