New setting clinical_assistant.citations_enabled (default true, admin checkbox). With it off, retrieval, grounding and every other rule are unchanged — answers are still built only from retrieved sources — but: - buildSystemPrompt swaps only the citation block: the "cite factual claims with [1]" rules are replaced with "do not include citations, source numbers or bracketed markers", and the note that the sourcing requirement itself is unchanged. Grounding, scope, table formatting and tone rules are byte-identical between the two modes. - The server strips any stray [n] the model emits anyway, from the stored answer rather than only the view, so saved chats and exports match what was shown. - No sources are sent to the client at all, and the status endpoint reports the mode so the UI hides the Sources panel and gives its 330px column back to the chat instead of showing an empty rail. Validated as a boolean in adminConfig, like the feature.* keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
422 lines
22 KiB
JavaScript
422 lines
22 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);
|
|
});
|
|
}
|
|
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();
|
|
}
|