Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The card holds more than one Save button and nothing said so. "Save image settings" is injected directly above "Save model & retrieval settings", with no indication of where one stops and the other starts, and the page saves nothing automatically. It now states that up front, and the bottom button says which settings it applies. "Retry loading settings" sat beside Save looking like an ordinary control, because it did: a bare button with the hidden attribute, which the browser's own [hidden] rule could not hide once .btn-sm set a display. It is now inside an error message that exists only on failure, says what failed, and says that nothing typed has been lost. The status line used to read "Settings ready." forever, which answers a question nobody asks. It now reports the thing an admin actually wants to know when they come back: whether the last save went through, and at what time. A toast is gone in three seconds; this stays on the page. The signed-out preview moves to Feature Flags, where it belongs. It was a second checkbox under a row labelled "Sources", followed by two paragraphs, the first about preview and the second about citations — so neither paragraph clearly belonged to either checkbox. It is stored as feature.assistant_preview now, with the old clinical_assistant.preview_enabled still honoured when the new key has never been written. That also means an ordinary admin can toggle it under ADMIN_LOCKDOWN: clinical_assistant.* is locked, and putting a day-to-day switch behind host access was never the intent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
366 lines
20 KiB
JavaScript
366 lines
20 KiB
JavaScript
function assistantBudgetMeta(budget, savedOverride) {
|
|
var limit = budget && Number.isInteger(budget.limit) ? budget.limit.toLocaleString() : null;
|
|
if (savedOverride) {
|
|
return 'Saved override in use. Clear this field to fall back to CLINICAL_ASSISTANT_CONVERSATION_CHARS' +
|
|
(limit ? ' (' + limit + ' UTF-16 code units).' : '.');
|
|
}
|
|
if (!limit) return 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.';
|
|
// conversationBudget() reports 'default' when the env var is unset, so naming
|
|
// the env var alone would be wrong in the most common deployment.
|
|
return budget.source === 'environment'
|
|
? 'Currently ' + limit + ' UTF-16 code units from CLINICAL_ASSISTANT_CONVERSATION_CHARS. Set a value here to override it.'
|
|
: 'Currently the built-in default of ' + limit + ' UTF-16 code units (CLINICAL_ASSISTANT_CONVERSATION_CHARS is unset). Set a value here to override it.';
|
|
}
|
|
|
|
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.
|
|
let imageRosterSaved = [];
|
|
let savedChatAllowed = [];
|
|
let savedImageAllowed = [];
|
|
let chatRoster = [];
|
|
let imageRoster = [];
|
|
|
|
function parseAssistantList(value) {
|
|
return String(value || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
|
}
|
|
function renderAssistantCheckboxList(containerId, candidates, saved, emptyText) {
|
|
var container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
var ids = candidates.slice();
|
|
saved.forEach(function(id) { if (ids.indexOf(id) === -1) ids.push(id); });
|
|
ids = ids.filter(Boolean).filter(function(id, i, all) { return all.indexOf(id) === i; }).sort();
|
|
container.innerHTML = '';
|
|
if (!ids.length) {
|
|
var empty = document.createElement('p');
|
|
empty.style.cssText = 'margin:0;font-size:12px;color:var(--g500);';
|
|
empty.textContent = emptyText || 'No models available. Add or enable models first.';
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
ids.forEach(function(id) {
|
|
var row = document.createElement('label');
|
|
row.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:12px;padding:2px 0;';
|
|
var box = document.createElement('input');
|
|
box.type = 'checkbox';
|
|
box.value = id;
|
|
box.checked = saved.indexOf(id) !== -1;
|
|
var span = document.createElement('span');
|
|
span.textContent = id;
|
|
row.appendChild(box);
|
|
row.appendChild(span);
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
function checkedAssistantModels(containerId) {
|
|
var container = document.getElementById(containerId);
|
|
if (!container) return [];
|
|
return Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]:checked'), function(box) { return box.value; });
|
|
}
|
|
// Built from the admin's roster, not from discovery: the gateway advertises
|
|
// dozens of image models, and this used to wait on a dropdown that no longer
|
|
// exists, so only four hard-coded fallbacks ever appeared.
|
|
function renderAssistantImageModelCheckboxes() {
|
|
imageRoster = imageRosterSaved.concat([window._assistantImageModelValue]);
|
|
renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed,
|
|
'No image models added yet. Add them under Image Generation below, then tick them here.');
|
|
}
|
|
// Adding or removing in the Image Generation card updates this list at once,
|
|
// keeping any ticks made here that have not been saved yet.
|
|
document.addEventListener('assistant-image-roster-changed', function(e) {
|
|
imageRosterSaved = (e.detail && Array.isArray(e.detail.roster)) ? e.detail.roster.slice() : imageRosterSaved;
|
|
if (configState !== 'ready') return;
|
|
savedImageAllowed = checkedAssistantModels('assistant-allowed-image-models');
|
|
renderAssistantImageModelCheckboxes();
|
|
});
|
|
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail && e.detail.tab === 'admin') loadAssistantAdmin();
|
|
});
|
|
|
|
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-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();
|
|
});
|
|
|
|
function updateAssistantLoadState() {
|
|
var save = document.getElementById('btn-save-assistant-config');
|
|
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.';
|
|
}
|
|
}
|
|
|
|
function loadAssistantAdmin() {
|
|
if (configState === 'loading' || configState === 'ready') return;
|
|
configState = 'loading';
|
|
updateAssistantLoadState();
|
|
Promise.all([
|
|
fetch('/api/models', { headers: getAuthHeaders() }).then(function(r) {
|
|
if (!r.ok) throw new Error('Model discovery failed');
|
|
return r.json();
|
|
}).catch(function() { return {}; }),
|
|
fetch('/api/admin/config', { headers: getAuthHeaders() }).then(function(r) {
|
|
if (!r.ok) throw new Error('Settings request failed');
|
|
return r.json();
|
|
})
|
|
]).then(function(results) {
|
|
var modelsData = results[0] || {};
|
|
var configData = results[1];
|
|
var budget = configData && configData.conversationBudget;
|
|
var validBudget = budget && Number.isInteger(budget.limit) && budget.limit >= 1000 && budget.limit <= 1000000 &&
|
|
budget.unit === 'characters' && budget.measure === 'UTF-16 code units' &&
|
|
budget.env === 'CLINICAL_ASSISTANT_CONVERSATION_CHARS' && ['environment', 'default'].includes(budget.source);
|
|
// Validate the entire response before touching any setting or draft.
|
|
if (!configData || configData.success !== true || !Array.isArray(configData.config) ||
|
|
!configData.config.every(function(row) { return row && typeof row.key === 'string' && row.key && typeof row.value === 'string'; }) ||
|
|
!validBudget) throw new Error('Invalid settings response');
|
|
var cfg = Object.create(null);
|
|
configData.config.forEach(function(row) { cfg[row.key] = row.value; });
|
|
|
|
var chatSelect = document.getElementById('assistant-chat-model');
|
|
if (chatSelect) {
|
|
var savedChatModel = cfg['clinical_assistant.chat_model'] || '';
|
|
var defaultLabel = modelsData.defaultModel ? ('Use global default (' + modelsData.defaultModel + ')') : 'Use global default';
|
|
chatSelect.innerHTML = '';
|
|
var defaultOpt = document.createElement('option');
|
|
defaultOpt.value = '';
|
|
defaultOpt.textContent = defaultLabel;
|
|
chatSelect.appendChild(defaultOpt);
|
|
(Array.isArray(modelsData.models) ? modelsData.models : []).filter(function(m) { return m && typeof m.id === 'string' && m.id; }).forEach(function(m) {
|
|
var opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name || m.id;
|
|
chatSelect.appendChild(opt);
|
|
});
|
|
if (savedChatModel && !Array.prototype.some.call(chatSelect.options, function(o) { return o.value === savedChatModel; })) {
|
|
var saved = document.createElement('option');
|
|
saved.value = savedChatModel;
|
|
saved.textContent = savedChatModel + ' (saved/custom)';
|
|
chatSelect.appendChild(saved);
|
|
}
|
|
chatSelect.value = savedChatModel;
|
|
}
|
|
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
|
|
savedChatAllowed = parseAssistantList(cfg['clinical_assistant.allowed_models']);
|
|
savedImageAllowed = parseAssistantList(cfg['clinical_assistant.allowed_image_models']);
|
|
imageRosterSaved = parseAssistantList(cfg['clinical_assistant.image_model_roster']);
|
|
window._assistantImageRoster = imageRosterSaved.slice();
|
|
// Only a nudge for the Image Generation card's buttons: it must never be
|
|
// able to fail the settings load itself.
|
|
try { document.dispatchEvent(new CustomEvent('assistant-image-roster', { detail: { roster: imageRosterSaved.slice() } })); } catch (e) {}
|
|
chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']);
|
|
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
|
|
renderAssistantImageModelCheckboxes();
|
|
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
|
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
|
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
|
|
var sourcesBox = document.getElementById('assistant-show-sources');
|
|
if (sourcesBox) {
|
|
var saved = cfg['clinical_assistant.show_sources'];
|
|
if (saved === undefined || saved === '') saved = cfg['clinical_assistant.citations_enabled']; // legacy key
|
|
sourcesBox.checked = String(saved) !== 'false';
|
|
}
|
|
var budgetInput = document.getElementById('assistant-conversation-budget');
|
|
var cfgBudget = cfg['clinical_assistant.conversation_chars'];
|
|
if (budgetInput) {
|
|
// Empty means "no override". Prefilling the environment value here would
|
|
// turn the next Save into an accidental override, making the documented
|
|
// "leave empty to use the environment" path unreachable.
|
|
budgetInput.value = cfgBudget ? String(cfgBudget) : '';
|
|
if (budget && Number.isInteger(budget.limit)) budgetInput.placeholder = String(budget.limit);
|
|
}
|
|
var budgetMeta = document.getElementById('assistant-conversation-budget-meta');
|
|
if (budgetMeta) budgetMeta.textContent = assistantBudgetMeta(budget, cfgBudget);
|
|
configState = 'ready';
|
|
updateAssistantLoadState();
|
|
loadAssistantPromptPoolStatus();
|
|
}).catch(function() {
|
|
configState = 'failed';
|
|
updateAssistantLoadState();
|
|
var budgetMetaFailed = document.getElementById('assistant-conversation-budget-meta');
|
|
if (budgetMetaFailed) budgetMetaFailed.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.';
|
|
});
|
|
}
|
|
|
|
function testAssistantChatModel() {
|
|
var model = getValue('assistant-chat-model');
|
|
var result = document.getElementById('assistant-chat-test-result');
|
|
if (!model) {
|
|
var select = document.getElementById('assistant-chat-model');
|
|
var selected = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex].textContent : 'global default';
|
|
if (result) result.textContent = 'Testing ' + selected + '...';
|
|
} else if (result) {
|
|
result.textContent = 'Testing ' + model + '...';
|
|
}
|
|
fetch('/api/admin/config/models/test', {
|
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model || getGlobalDefaultFromAssistantSelect() })
|
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Chat model test failed');
|
|
if (result) result.textContent = 'Chat model OK (' + data.duration + ' ms): ' + (data.response || '').trim();
|
|
showToast('Chat model works', 'success');
|
|
}).catch(function(err) {
|
|
if (result) result.textContent = err.message;
|
|
showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function getGlobalDefaultFromAssistantSelect() {
|
|
var select = document.getElementById('assistant-chat-model');
|
|
if (!select || !select.options[0]) return '';
|
|
var match = select.options[0].textContent.match(/\((.+)\)$/);
|
|
return match ? match[1] : '';
|
|
}
|
|
|
|
function loadAssistantPromptPoolStatus() {
|
|
var result = document.getElementById('assistant-prompt-pool-status');
|
|
if (result) result.textContent = 'Checking prompt pool...';
|
|
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
|
|
renderAssistantPromptPoolStatus(data.meta);
|
|
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
|
})
|
|
.catch(function(err) {
|
|
if (result) result.textContent = err.message;
|
|
});
|
|
}
|
|
|
|
function regenerateAssistantPromptPool() {
|
|
var result = document.getElementById('assistant-prompt-pool-status');
|
|
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
|
|
if (btn) btn.disabled = true;
|
|
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
|
|
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
|
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
|
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
|
|
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
|
|
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
|
showToast('Prompt pool regenerated', 'success');
|
|
}).catch(function(err) {
|
|
if (result) result.textContent = err.message;
|
|
showToast(err.message, 'error');
|
|
}).finally(function() {
|
|
if (btn) btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
function renderAssistantPromptPoolStatus(meta) {
|
|
var result = document.getElementById('assistant-prompt-pool-status');
|
|
if (!result) return;
|
|
if (!meta) {
|
|
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
|
|
return;
|
|
}
|
|
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
|
|
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
|
|
}
|
|
|
|
function renderAssistantPromptPoolSnapshots(snapshots) {
|
|
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
|
if (!select) return;
|
|
select.innerHTML = '';
|
|
if (!snapshots.length) {
|
|
var empty = document.createElement('option');
|
|
empty.value = '';
|
|
empty.textContent = 'No saved snapshots';
|
|
select.appendChild(empty);
|
|
return;
|
|
}
|
|
snapshots.forEach(function(s) {
|
|
var opt = document.createElement('option');
|
|
opt.value = s.id;
|
|
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
|
|
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function restoreAssistantPromptPool() {
|
|
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
|
var id = select ? select.value : '';
|
|
var result = document.getElementById('assistant-prompt-pool-status');
|
|
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
|
|
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
|
|
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
|
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
|
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
|
|
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
|
|
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
|
showToast('Prompt pool restored', 'success');
|
|
}).catch(function(err) {
|
|
if (result) result.textContent = err.message;
|
|
showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function saveAssistantAdmin() {
|
|
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(','))
|
|
]).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');
|
|
}).catch(function(err) {
|
|
if (status) status.textContent = 'Not saved. Nothing was changed.';
|
|
showToast(err.message || 'Save failed', 'error');
|
|
});
|
|
}
|
|
|
|
function putAssistantConfig(key, value) {
|
|
return fetch('/api/admin/config/' + encodeURIComponent(key), {
|
|
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: value })
|
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Save failed');
|
|
return data;
|
|
});
|
|
}
|
|
function chatOptions() {
|
|
var chat = document.getElementById('assistant-chat-model');
|
|
var out = [];
|
|
if (chat) Array.prototype.forEach.call(chat.options, function(o) { out.push({ value: o.value }); });
|
|
return out;
|
|
}
|
|
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
|
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
|
|
const escAssistant = adminEscapeHtml;
|
|
|
|
// Catch-up: when the admin tab is already active and loaded at module init
|
|
// (e.g. restored tab before this module evaluated), tabChanged may never fire
|
|
// again. The loader's own state guards make this idempotent with tabChanged.
|
|
var adminTab = document.getElementById('admin-tab');
|
|
if (adminTab && adminTab.classList.contains('active') && adminTab.dataset.loaded === '1') loadAssistantAdmin();
|
|
}
|