Multimodal removal The multimodal path called nc_multimodal_search against a second hardcoded collection whose embedding service (multimodal-embeddings:7999) was never deployed and ENABLE_MULTIMODAL_RAG has always been false, so it only ever logged "multimodal search skipped". Removed rather than left as dead weight: - clinicalRetrieval: normalizeMcpMultimodalResponse, isVisualSourceQuery, isRadiologyQuery, buildMultimodalSearchQuery, classifyAndRerankMultimodalResults, selectMultimodalResults, visualIntent, visualMetadataScore, shouldRejectVisualSource, allowsFrontMatterQuery, looksLikeFrontMatterPage, looksLikeTextOnlyPage and MULTIMODAL_CANDIDATE_LIMIT (~140 lines). - clinicalMcpClient: multimodalSearch. - The route's visual/text slot split is gone; the whole search limit is text. - The "[visual PDF page match]" prompt label and the "visual PDF page" source badge are gone with it. Adding models Model availability could only be ticked from what the gateway advertised, so an admin could never offer a model discovery did not list. Each list now has a text field: a typed id joins the same checkbox list, is enabled by default, is de-duplicated, and persists through the normal allowed_models save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
454 lines
24 KiB
JavaScript
454 lines
24 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';
|
|
const IMAGE_MODEL_FALLBACKS = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'];
|
|
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) {
|
|
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 = '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);
|
|
});
|
|
}
|
|
// Discovery only returns what the gateway advertises, so an admin could never
|
|
// offer a model it does not list. Typed ids join the same checkbox list and
|
|
// persist through the normal allowed_models save.
|
|
function addAssistantModel(containerId, id) {
|
|
var container = document.getElementById(containerId);
|
|
id = String(id || '').trim();
|
|
if (!container || !id) return false;
|
|
var existing = Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]'), function(b) { return b.value; });
|
|
if (existing.indexOf(id) !== -1) {
|
|
var already = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
|
|
if (already) already.checked = true;
|
|
return true;
|
|
}
|
|
var roster = containerId === 'assistant-allowed-chat-models' ? chatRoster : imageRoster;
|
|
roster.push(id);
|
|
renderAssistantCheckboxList(containerId, roster, existing.filter(function(v) {
|
|
var box = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(v) : v) + '"]');
|
|
return box && box.checked;
|
|
}).concat([id]));
|
|
return true;
|
|
}
|
|
|
|
document.addEventListener('click', function(event) {
|
|
var button = event.target.closest && event.target.closest('[data-assistant-add-model]');
|
|
if (!button) return;
|
|
var containerId = button.getAttribute('data-assistant-add-model');
|
|
var field = document.getElementById(containerId === 'assistant-allowed-chat-models' ? 'assistant-add-chat-model' : 'assistant-add-image-model');
|
|
if (!field) return;
|
|
if (addAssistantModel(containerId, field.value)) field.value = '';
|
|
else if (typeof window.showToast === 'function') window.showToast('Enter a model id first', 'error');
|
|
});
|
|
|
|
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; });
|
|
}
|
|
function renderAssistantImageModelCheckboxes(models) {
|
|
imageRoster = (Array.isArray(models) ? models : []).map(function(m) { return m.id; }).concat(IMAGE_MODEL_FALLBACKS, [window._assistantImageModelValue]);
|
|
renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed);
|
|
}
|
|
|
|
let imageModelsLoading = false;
|
|
|
|
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();
|
|
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
|
|
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
|
|
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
|
|
});
|
|
|
|
function updateAssistantLoadState() {
|
|
var save = document.getElementById('btn-save-assistant-config');
|
|
if (save) save.disabled = configState !== 'ready' || imageModelsLoading;
|
|
var retry = document.getElementById('btn-retry-assistant-config');
|
|
if (retry) retry.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...' :
|
|
imageModelsLoading ? 'Loading image models; saving is unavailable until discovery finishes.' : 'Settings ready.';
|
|
}
|
|
|
|
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']);
|
|
chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']);
|
|
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
|
|
renderAssistantImageModelCheckboxes([]);
|
|
renderAssistantImageModels([], window._assistantImageModelValue);
|
|
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 citationsBox = document.getElementById('assistant-citations-enabled');
|
|
if (citationsBox) citationsBox.checked = String(cfg['clinical_assistant.citations_enabled']) !== '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();
|
|
loadAssistantImageModels();
|
|
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 renderAssistantImageModels(models, current) {
|
|
var sel = document.getElementById('assistant-image-model');
|
|
if (!sel) return;
|
|
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
|
|
models.forEach(function(m) {
|
|
var opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name || m.id;
|
|
sel.appendChild(opt);
|
|
});
|
|
if (current && !Array.prototype.some.call(sel.options, function(o) { return o.value === current; })) {
|
|
var custom = document.createElement('option');
|
|
custom.value = current;
|
|
custom.textContent = current + ' (saved/custom)';
|
|
sel.appendChild(custom);
|
|
}
|
|
sel.value = current;
|
|
}
|
|
|
|
function loadAssistantImageModels() {
|
|
var sel = document.getElementById('assistant-image-model');
|
|
if (!sel || configState !== 'ready' || imageModelsLoading) return;
|
|
// Keep the real selection, never an empty loading option. Read it again on
|
|
// completion so a newer custom selection wins over an in-flight discovery.
|
|
imageModelsLoading = true;
|
|
sel.disabled = true;
|
|
updateAssistantLoadState();
|
|
fetch('/api/admin/config/image-models/discover', { headers: getAuthHeaders() })
|
|
.then(function(r) {
|
|
if (!r.ok) throw new Error('Image discovery failed');
|
|
return r.json();
|
|
})
|
|
.then(function(data) {
|
|
if (!data || data.success === false || !Array.isArray(data.models) ||
|
|
!data.models.every(function(m) { return m && typeof m.id === 'string' && m.id; })) throw new Error('Invalid image models');
|
|
renderAssistantImageModelCheckboxes(data.models);
|
|
renderAssistantImageModels(data.models, sel.value);
|
|
})
|
|
.catch(function() {
|
|
renderAssistantImageModels(['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'].map(function(id) { return { id: id }; }), sel.value);
|
|
})
|
|
.finally(function() {
|
|
imageModelsLoading = false;
|
|
sel.disabled = false;
|
|
updateAssistantLoadState();
|
|
});
|
|
}
|
|
|
|
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 testAssistantImageModel() {
|
|
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
|
|
var result = document.getElementById('assistant-image-test-result');
|
|
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing ' + escAssistant(model) + '...';
|
|
fetch('/api/admin/config/image-models/test', {
|
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model })
|
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Image test failed');
|
|
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
|
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
|
|
showToast('Image model works', 'success');
|
|
}).catch(function(err) {
|
|
if (result) result.textContent = err.message;
|
|
showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function useCustomAssistantImageModel() {
|
|
var input = document.getElementById('assistant-custom-image-model');
|
|
var sel = document.getElementById('assistant-image-model');
|
|
var model = input ? input.value.trim() : '';
|
|
if (!model) { showToast('Enter an image model ID', 'error'); return; }
|
|
if (sel && !Array.prototype.some.call(sel.options, function(o) { return o.value === model; })) {
|
|
var opt = document.createElement('option');
|
|
opt.value = model;
|
|
opt.textContent = model + ' (custom)';
|
|
sel.appendChild(opt);
|
|
}
|
|
if (sel) sel.value = model;
|
|
window._assistantImageModelValue = model;
|
|
showToast('Custom image model selected. Save settings to keep it.', 'info');
|
|
}
|
|
|
|
function saveAssistantAdmin() {
|
|
if (configState !== 'ready' || imageModelsLoading) 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.citations_enabled',
|
|
(document.getElementById('assistant-citations-enabled') || {}).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() {
|
|
if (status) status.textContent = '';
|
|
showToast('Assistant settings saved', 'success');
|
|
}).catch(function(err) {
|
|
if (status) status.textContent = '';
|
|
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();
|
|
}
|