Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
525 lines
28 KiB
JavaScript
525 lines
28 KiB
JavaScript
import { saveImageSettings } from './imageSettings.js';
|
|
|
|
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 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 = [];
|
|
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 on the roster yet. Add them under Discover & test, then tick them here.');
|
|
}
|
|
// 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;
|
|
savedImageAllowed = checkedAssistantModels('assistant-allowed-image-models');
|
|
renderAssistantImageModelCheckboxes();
|
|
});
|
|
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail && e.detail.tab === 'admin') loadAssistantAdmin();
|
|
});
|
|
|
|
// The roster changed while this card was already on screen. Its loader is
|
|
// guarded so it runs once per visit, which is right for a tab change and
|
|
// wrong here — without this, a model added in the Models card did not appear
|
|
// in the chat, allowed-models or review pickers until the page was reloaded.
|
|
document.addEventListener('models-changed', function() {
|
|
// The loader keeps unsaved drafts, so re-running it costs nothing but a
|
|
// refreshed list of options.
|
|
if (configState === 'loading') return;
|
|
configState = 'idle';
|
|
loadAssistantAdmin();
|
|
});
|
|
|
|
document.addEventListener('click', function(e) {
|
|
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();
|
|
});
|
|
|
|
// ── Slide review ────────────────────────────────────────────────────
|
|
// 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.
|
|
function renderReviewModel(models, saved) {
|
|
var select = document.getElementById('mr-review-model');
|
|
if (!select) return;
|
|
select.innerHTML = '';
|
|
var off = document.createElement('option');
|
|
off.value = '';
|
|
off.textContent = 'Off — do not review decks';
|
|
select.appendChild(off);
|
|
(Array.isArray(models) ? models : []).filter(function(m) { return m && m.id; }).forEach(function(m) {
|
|
var opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name || m.id;
|
|
select.appendChild(opt);
|
|
});
|
|
if (saved && !Array.prototype.some.call(select.options, function(o) { return o.value === saved; })) {
|
|
var kept = document.createElement('option');
|
|
kept.value = saved;
|
|
kept.textContent = saved + ' (saved/custom)';
|
|
select.appendChild(kept);
|
|
}
|
|
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() {
|
|
['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.
|
|
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() {
|
|
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;
|
|
}
|
|
renderReviewModel(modelsData.models, cfg['my_resources.review_model'] || '');
|
|
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 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);
|
|
renderAssistantImageModelCheckboxes();
|
|
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
|
setValue('assistant-indexer-url', cfg['clinical_assistant.indexer_url'] || '');
|
|
setValue('assistant-indexer-token', ''); // never echoed back; blank means keep
|
|
loadLibraryIndexStatus();
|
|
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');
|
|
});
|
|
}
|
|
|
|
// The library index: what the indexer reports, and the one button that asks
|
|
// it to reconcile. Neither depends on the settings load succeeding.
|
|
function describeIndexStatus(data) {
|
|
if (!data || !data.success) return (data && data.error) || 'Indexer not reachable.';
|
|
var st = data.status || {};
|
|
var sc = st.scanner || {};
|
|
var parts = [];
|
|
if (st.indexed_documents != null) parts.push(st.indexed_documents + ' documents indexed');
|
|
if (st.pending_documents) parts.push(st.pending_documents + ' waiting');
|
|
if (sc.scan_running) parts.push('a scan is running now');
|
|
else if (sc.last_scan_finished_at) parts.push('last scan finished ' + new Date(sc.last_scan_finished_at * 1000).toLocaleString());
|
|
parts.push(sc.on_demand ? 'runs only when asked' : 'runs every ' + Math.round((sc.scan_interval_seconds || 0) / 60) + ' min');
|
|
if (!data.tokenConfigured) parts.push('no trigger token set');
|
|
return parts.join(' · ');
|
|
}
|
|
function loadLibraryIndexStatus() {
|
|
var box = document.getElementById('assistant-index-status');
|
|
if (!box) return;
|
|
fetch('/api/admin/config/library-index', { credentials: 'same-origin' }).then(function(r) { return r.json(); }).then(function(data) {
|
|
box.textContent = describeIndexStatus(data);
|
|
}).catch(function(err) { box.textContent = 'Could not read the indexer status: ' + err.message; });
|
|
}
|
|
function runLibraryIndexNow() {
|
|
var button = document.getElementById('btn-assistant-index-now');
|
|
var box = document.getElementById('assistant-index-status');
|
|
if (button) button.disabled = true;
|
|
fetch('/api/admin/config/library-index/scan', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
.then(function(r) { return r.json(); }).then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'The indexer refused');
|
|
var r = data.result || {};
|
|
showToast(r.status === 'already running' ? 'A scan is already running' : 'Indexing started', 'success');
|
|
if (box) box.textContent = r.status === 'already running' ? 'A scan is already running; it will pick up the same changes.' : 'Scan requested. New and changed documents are being queued…';
|
|
setTimeout(loadLibraryIndexStatus, 4000);
|
|
}).catch(function(err) { showToast(err.message, 'error'); if (box) box.textContent = err.message; })
|
|
.finally(function() { if (button) button.disabled = false; });
|
|
}
|
|
// The indexer's address and token have their own Save: the card's Save & Close
|
|
// writes exactly the five keys it shows, and a token is not a setting to
|
|
// rewrite on every visit.
|
|
function saveIndexerSettings() {
|
|
if (configState !== 'ready') return;
|
|
var writes = [putAssistantConfig('clinical_assistant.indexer_url', getValue('assistant-indexer-url') || '')];
|
|
if (getValue('assistant-indexer-token')) writes.push(putAssistantConfig('clinical_assistant.indexer_token', getValue('assistant-indexer-token')));
|
|
Promise.all(writes).then(function() {
|
|
setValue('assistant-indexer-token', '');
|
|
showToast('Indexer settings saved', 'success');
|
|
loadLibraryIndexStatus();
|
|
}).catch(function(err) { showToast(err.message || 'Save failed', 'error'); });
|
|
}
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest) return;
|
|
if (e.target.closest('#btn-assistant-index-now')) runLibraryIndexNow();
|
|
else if (e.target.closest('#btn-assistant-index-refresh')) loadLibraryIndexStatus();
|
|
else if (e.target.closest('#btn-assistant-indexer-save')) saveIndexerSettings();
|
|
});
|
|
|
|
// 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 status = document.getElementById('assistant-admin-status');
|
|
if (status) status.textContent = 'Saving...';
|
|
Promise.all([
|
|
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')
|
|
]).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(results) {
|
|
var reviewer = getValue('mr-review-model');
|
|
// The image models named back. "Saved" does not tell an administrator
|
|
// that the model they chose is the one that will draw — which is the
|
|
// whole question, and the reason this card was hard to trust.
|
|
var images = Array.isArray(results[results.length - 1]) ? results[results.length - 1] : [];
|
|
if (status) {
|
|
status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '. ' +
|
|
(images.length ? 'Images — ' + images.join('; ') + '. ' : '') +
|
|
(reviewer ? 'Decks 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 })
|
|
}).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();
|
|
}
|