Lets a visitor who is not signed in try the Clinical Assistant. They can ask questions and read the answer; everything else asks them to sign in. Nothing is saved to any account, because a preview visitor has no account.
-
+
+
@@ -316,9 +325,15 @@
Clinical Assistant / Learning
- Model and prompt settings
+ 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.
+
@@ -362,18 +377,13 @@
- Sources
+ Citations
-
-
Preview is read-only: visitors can try the assistant, and anything else asks them to sign in or create an account.
-
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.
@@ -398,10 +408,18 @@
Loading Learning prompts...
-
-
-
-
+
+
+
+ These settings could not be loaded, so saving is disabled. Nothing you have typed has been lost.
+
+
+
+
+
+ Applies everything in this card except the image settings above.
+
diff --git a/public/js/admin.js b/public/js/admin.js
index 05d9d220..c410bf66 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -378,6 +378,12 @@ function adminTabActive() {
var flagNextcloud = document.getElementById('cms-flag-nextcloud');
if (flagReadAloud) flagReadAloud.value = cfg['feature.read_aloud'] !== undefined ? cfg['feature.read_aloud'] : 'true';
if (flagNextcloud) flagNextcloud.value = cfg['feature.nextcloud'] !== undefined ? cfg['feature.nextcloud'] : 'true';
+ var flagPreview = document.getElementById('cms-flag-assistant-preview');
+ // Falls back to the key this setting used to live under, so the switch
+ // shows the real state on an installation that set it before the move.
+ if (flagPreview) flagPreview.value =
+ cfg['feature.assistant_preview'] !== undefined ? cfg['feature.assistant_preview'] :
+ (cfg['clinical_assistant.preview_enabled'] !== undefined ? cfg['clinical_assistant.preview_enabled'] : 'false');
// Auto-delete setting
var autoDeleteDays = cfg['site.auto_delete_days'] || '7';
@@ -419,12 +425,24 @@ function adminTabActive() {
var readAloud = document.getElementById('cms-flag-read-aloud').value;
var nextcloud = document.getElementById('cms-flag-nextcloud').value;
+ var preview = (document.getElementById('cms-flag-assistant-preview') || {}).value;
+ var status = document.getElementById('cms-flags-status');
+ if (status) status.textContent = 'Saving...';
+
Promise.all([
putConfig('feature.read_aloud', readAloud),
- putConfig('feature.nextcloud', nextcloud)
+ putConfig('feature.nextcloud', nextcloud),
+ // A feature flag, stored under feature.* like the others. The assistant
+ // still reads the old clinical_assistant.preview_enabled key when this one
+ // has never been set, so existing installations keep their setting.
+ putConfig('feature.assistant_preview', preview === 'true' ? 'true' : 'false')
]).then(function() {
+ if (status) status.textContent = 'Saved.';
showToast('Feature flags saved', 'success');
- }).catch(function() { showToast('Save failed', 'error'); });
+ }).catch(function() {
+ if (status) status.textContent = 'Not saved.';
+ showToast('Save failed', 'error');
+ });
}
// ---- EMAIL TEMPLATES ----
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js
index 76f6a8be..e561c7ad 100644
--- a/public/js/admin/clinicalAssistant.js
+++ b/public/js/admin/clinicalAssistant.js
@@ -91,13 +91,18 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
function updateAssistantLoadState() {
var save = document.getElementById('btn-save-assistant-config');
if (save) save.disabled = configState !== 'ready';
- var retry = document.getElementById('btn-retry-assistant-config');
- if (retry) retry.hidden = configState !== 'failed';
+ // 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');
- if (status) status.textContent = configState === 'failed' ?
- 'Settings load failed. Drafts are unchanged. Retry loading settings or revisit the Admin tab.' :
- configState !== 'ready' ? 'Loading assistant settings...' :
- 'Settings ready.';
+ // 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.';
+ }
}
function loadAssistantAdmin() {
@@ -170,8 +175,6 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
if (saved === undefined || saved === '') saved = cfg['clinical_assistant.citations_enabled']; // legacy key
sourcesBox.checked = String(saved) !== 'false';
}
- var previewBox = document.getElementById('assistant-preview-enabled');
- if (previewBox) previewBox.checked = String(cfg['clinical_assistant.preview_enabled']) === 'true';
var budgetInput = document.getElementById('assistant-conversation-budget');
var cfgBudget = cfg['clinical_assistant.conversation_chars'];
if (budgetInput) {
@@ -322,16 +325,17 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
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.preview_enabled',
- (document.getElementById('assistant-preview-enabled') || {}).checked === true ? 'true' : 'false'),
putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')),
putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(','))
]).then(function() {
- if (status) status.textContent = '';
+ // 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');
}).catch(function(err) {
- if (status) status.textContent = '';
+ if (status) status.textContent = 'Not saved. Nothing was changed.';
showToast(err.message || 'Save failed', 'error');
});
}
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index e8636bde..71238970 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -59,8 +59,17 @@ var PREVIEW_PATHS = new Set([
]);
var PREVIEW_USER = Object.freeze({ id: null, preview: true, role: 'preview' });
+// It is a feature flag and now lives with the others under feature.*, which also
+// means an ordinary admin can still toggle it under ADMIN_LOCKDOWN — the
+// clinical_assistant.* prefix is locked, and burying a day-to-day switch behind
+// host access was not the intent.
+//
+// The old key is still honoured when the new one has never been written, so an
+// installation that enabled preview before the move keeps it enabled.
async function previewEnabled() {
- return String(await getSetting('clinical_assistant.preview_enabled', 'false')) === 'true';
+ var flag = await getSetting('feature.assistant_preview', '');
+ if (String(flag) === '') flag = await getSetting('clinical_assistant.preview_enabled', 'false');
+ return String(flag) === 'true';
}
router.use(async function(req, res, next) {
diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js
index 42f6081e..893912da 100644
--- a/test/admin-clinical-assistant-wiring.test.js
+++ b/test/admin-clinical-assistant-wiring.test.js
@@ -79,9 +79,13 @@ 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();
- assert.equal(writes().length, 9);
+ // 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);
+ 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.preview_enabled', 'clinical_assistant.search_limit', 'clinical_assistant.show_sources', 'clinical_assistant.translate_provider'
+ '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'
]);
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js
index c841bdad..4ec8708e 100644
--- a/test/clinical-release-integration.test.js
+++ b/test/clinical-release-integration.test.js
@@ -85,7 +85,8 @@ 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);
- assert.equal(calls.filter(call => call.options.method === 'PUT').length, 9, 'one native admin initializer; prompts are not generic setting saves');
+ // 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');
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 a36915a7..7f64363e 100644
--- a/test/frontend-prompt-env.test.js
+++ b/test/frontend-prompt-env.test.js
@@ -289,7 +289,15 @@ 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.match(setting(ui, 'admin-status').textContent, /failed|unavailable/i);
+ // 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.
+ const errorBox = ui.document.getElementById('assistant-config-error');
+ assert.equal(errorBox.hidden, false, 'a failed load has to say so');
+ assert.match(errorBox.textContent, /could not be loaded/i);
+ assert.match(errorBox.textContent, /nothing you have typed has been lost/i);
+ assert.ok(ui.document.getElementById('btn-retry-assistant-config'),
+ 'and the retry has to be reachable from inside it');
assert.equal(setting(ui, 'chat-model').options.length, 0);
assert.equal(ui.document.getElementById('workflow-image-settings').children.length, 0, 'image settings load only after config success');
assert.equal(ui.calls.some(c => c.url.endsWith('/image-models/discover')), false);
@@ -315,8 +323,11 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
assert.equal(setting(ui, 'conversation-budget').value, '', 'no saved override, so the field stays empty');
assert.equal(setting(ui, 'conversation-budget').placeholder, '240000', 'environment metadata shows as the placeholder');
assert.equal(setting(ui, 'conversation-budget').readOnly, false);
- assert.match(setting(ui, 'admin-status').textContent, /ready/i);
- assert.equal(retry.hidden, true);
+ // Idle no longer claims 'ready'. What an admin actually needs to know is
+ // whether their change was applied, so the line reports that instead.
+ assert.match(setting(ui, 'admin-status').textContent, /Settings loaded|Saved /i);
+ // The error box hides on a successful load; the retry inside it goes with it.
+ assert.equal(ui.document.getElementById('assistant-config-error').hidden, true);
adminVisit(ui); adminVisit(ui); await tick();
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';
@@ -328,7 +339,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
['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.preview_enabled', 'false'],
+ ['clinical_assistant.show_sources', 'true'],
['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', '']
]);
});