refactor: rebuild the admin settings page around what each card does
The page had grown by accretion: model discovery scattered across five cards with a search box each, one Save writing eight keys from the bottom of a card that also held a second Save for something else, and a banner apologising that a button "applies only the settings above it". Now it reads in groups — Accounts, Models, Assistant & prompts, Site — and every card is a <details> that folds, so Save & Close means something. The rule is that each card saves exactly what it shows, which is what removed the need for the banner. Models is one workflow in three steps. Discover & test has a single search box and a kind switch (chat / image / speech / transcription / embedding); the five discovery calls are unchanged, the switch only decides which one answers. Roster is what has been added, including the image roster, which had no visible list before. Availability is the chat model, the two allowed lists, the per-workflow image settings and the slide reviewer, under one Save. Splitting the eight-key save follows from that rule: Save & Close writes the five retrieval and citation keys; Save availability writes the chat model, both allowed lists, the reviewer and the three image-settings PUTs. No route, request shape or setting key changed. Switching kind clears the results first — a row button would otherwise add an image model to the chat roster. The kind switch dispatches its event through document.defaultView's CustomEvent. jsdom refuses one built from another realm, and the existing announceModelsChanged() has exactly that bug: its event is built from the Node global, dispatchEvent refuses it, and a try/catch swallows the error — so models-changed propagation has only ever been source-grepped, never actually tested. Left alone here to keep this change to one subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
c68e3a6219
commit
9df2230479
11 changed files with 789 additions and 568 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = '<div style="padding:10px 12px;background:var(--g50);border-radius:6px;font-size:13px;color:var(--g600);">' +
|
||||
'<p style="margin:0 0 8px;"><i class="fas fa-info-circle" style="color:var(--blue);"></i> <strong>LiteLLM mode:</strong> No built-in models. ' +
|
||||
'Use <strong>Search API</strong> below to discover models from your proxy, then add them.</p>' +
|
||||
'Use <strong>Discover & test</strong> above to find models on your gateway, then add them.</p>' +
|
||||
'<button id="btn-clear-all-models" class="btn-sm" style="background:var(--red-light);color:var(--red);border:none;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;">' +
|
||||
'<i class="fas fa-trash"></i> Clear all added models</button></div>';
|
||||
} 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 = '<p style="color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Querying provider API...</p>';
|
||||
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 = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models. Click + to add to your model list.</p>' +
|
||||
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' chat models. Press + to add one to the roster.</p>' +
|
||||
data.models.slice(0, 100).map(function(m) {
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
|
||||
|
|
@ -1094,7 +1101,7 @@ initImageSettings();
|
|||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Custom / Discovered Models</label>' +
|
||||
container.innerHTML = '<div class="admin-subhead" style="margin-bottom:4px;">Added from the gateway</div>' +
|
||||
custom.map(function(m) {
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
||||
|
|
@ -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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||
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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||
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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||
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
|
||||
? '<button class="btn-sm btn-ghost admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="In the Clinical Assistant list. Press to remove." style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-check"></i> Added</button>'
|
||||
: '<button class="btn-sm btn-primary admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="Add to the Clinical Assistant\'s Image models list" style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>';
|
||||
? '<button class="btn-sm btn-ghost admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="On the image roster. Press to remove." style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-check"></i> Added</button>'
|
||||
: '<button class="btn-sm btn-primary admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="Add to the image roster" style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>';
|
||||
}
|
||||
|
||||
// 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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||
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(); })
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'\]\)/);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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/'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue