Rick
HF demo: block chat submissions and guide local edge install; add optional virgin-system runtime config
35e029d
/* =============================================================================
* Author: Rick Escher
* Project: SailingMedAdvisor
* Context: Google HAI-DEF Framework
* Models: Google MedGemmas
* Program: Kaggle Impact Challenge
* ========================================================================== */
/*
File: static/js/chat.js
Author notes: Front-end controller for MedGemma AI chat interface.
Key Responsibilities:
- Dual-mode chat system (Triage vs Inquiry consultations)
- Dynamic prompt composition with patient context injection
- Privacy/logging toggle (saves history or runs ephemeral)
- Model selection and performance metrics tracking
- Chat session restoration (resume previous consultations)
- Triage-specific metadata fields (consciousness, breathing, pain, etc.)
- LocalStorage persistence of chat state across sessions
- Real-time prompt preview with edit capability
- Integration with crew/patient selection from crew.js
Chat Modes:
1. TRIAGE MODE: Medical emergency assessment with structured fields
- Includes: patient condition snapshot + clinical triage pathway selectors
- Optimized for rapid emergency decision support
- Visual: Red theme (#ffecec background)
2. INQUIRY MODE: General medical questions and consultations
- Open-ended questions without structured fields
- Used for non-emergency medical information
- Visual: Green theme (#e6f5ec background)
Data Flow:
- User input → Prompt builder → API /api/chat → AI model → Response display
- Chat logs saved to history.json (unless logging disabled)
- Patient context injected from crew selection
- Prompt customization via expandable editor
Privacy Modes:
- LOGGING ON: Saves all chats to history.json, associates with crew member
- LOGGING OFF: Ephemeral mode, no persistence, clears on page refresh
LocalStorage Keys:
- sailingmed:lastPrompt: Most recent user message
- sailingmed:lastPatient: Selected crew member ID
- sailingmed:lastChatMode: Current mode (triage/inquiry)
- sailingmed:loggingOff: Privacy toggle state (1=off, 0=on)
- sailingmed:promptPreviewOpen: Prompt editor expanded state
- sailingmed:chatState: Per-mode input field persistence
- sailingmed:skipLastChat: Flag to skip restoring last chat on load
Integration Points:
- crew.js: Patient selection dropdown, history display
- settings.js: Custom prompts, model configuration
- main.js: Tab navigation, data loading coordination
*/
// HTML escaping utility (from utils.js or fallback)
const escapeHtml = (window.Utils && window.Utils.escapeHtml) ? window.Utils.escapeHtml : (str) => str;
const renderAssistantMarkdownChat = (window.Utils && window.Utils.renderAssistantMarkdown)
? window.Utils.renderAssistantMarkdown
: (txt) => (window.marked && typeof window.marked.parse === 'function')
? window.marked.parse(txt || '', { gfm: true, breaks: true })
: escapeHtml(txt || '').replace(/\n/g, '<br>');
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
// Privacy state: true = logging disabled (ephemeral), false = logging enabled
let isPrivate = false;
// Most recent user message (persisted to localStorage)
let lastPrompt = '';
// Flag to prevent concurrent chat submissions
let isProcessing = false;
// Current chat mode: 'triage' or 'inquiry'
let currentMode = 'triage';
// Active consultation session state, tracked independently by mode.
function createEmptyModeSession() {
return {
sessionId: null,
sessionMeta: null,
transcript: [],
promptBase: '',
};
}
const modeSessions = {
triage: createEmptyModeSession(),
inquiry: createEmptyModeSession(),
};
/**
* normalizeMode: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function normalizeMode(mode) {
return mode === 'inquiry' ? 'inquiry' : 'triage';
}
/**
* getModeSession: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function getModeSession(mode = currentMode) {
return modeSessions[normalizeMode(mode)];
}
// LocalStorage persistence keys
const LAST_PROMPT_KEY = 'sailingmed:lastPrompt';
const LAST_PATIENT_KEY = 'sailingmed:lastPatient';
const LAST_CHAT_MODE_KEY = 'sailingmed:lastChatMode';
const LOGGING_MODE_KEY = 'sailingmed:loggingOff';
const PROMPT_PREVIEW_STATE_KEY = 'sailingmed:promptPreviewOpen';
const PROMPT_PREVIEW_CONTENT_KEY = 'sailingmed:promptPreviewContent';
const CHAT_STATE_KEY = 'sailingmed:chatState';
const SKIP_LAST_CHAT_KEY = 'sailingmed:skipLastChat';
const EMPTY_RESPONSE_PLACEHOLDER_TEXT = 'No consultation response yet. Expand "Start New Triage Consultation" above and submit to generate guidance.';
const NO_LOCAL_MODELS_MESSAGE = 'No local MedGemma models are installed. Open Settings -> Offline Readiness Check to download at least one model.';
const HF_DEMO_UNAVAILABLE_MESSAGE = 'This Hugging Face-hosted build is a demo of an edge system. MedGemma triage/inquiry execution is not available here. Install SailingMedAdvisor locally to run full consultations.';
const MODEL_CHOICES = [
{ value: 'google/medgemma-1.5-4b-it', label: 'medgemma-1.5-4b-it (local)' },
{ value: 'google/medgemma-27b-text-it', label: 'medgemma-27b-text-it (local)' },
];
let modelAvailabilityState = {
loaded: false,
hasAnyRunnableModel: true,
hasAnyLocalModel: true,
runnableModels: MODEL_CHOICES.map((m) => m.value),
availableModels: MODEL_CHOICES.map((m) => m.value),
missingModels: [],
inferenceMode: 'local',
remoteTokenSet: false,
message: '',
};
let modelSelectSignature = '';
const PUBLIC_DEMO_DISABLE_CHAT = !!(window.SMA_RUNTIME && window.SMA_RUNTIME.publicDemoDisableChat);
const PUBLIC_DEMO_REPO_URL = String(
(window.SMA_RUNTIME && window.SMA_RUNTIME.publicDemoRepoUrl)
|| 'https://github.com/rickeae/SailingMedAdvisor'
);
function isPublicDemoChatDisabled() {
const runtimeFlag = !!(window.SMA_RUNTIME && window.SMA_RUNTIME.isHfSpace);
return PUBLIC_DEMO_DISABLE_CHAT || runtimeFlag || isHfHostedRuntime();
}
function closePublicDemoChatDisabledModal() {
const modal = document.getElementById('public-demo-chat-disabled-modal');
if (modal) modal.style.display = 'none';
}
function showPublicDemoChatDisabledModal() {
const modal = document.getElementById('public-demo-chat-disabled-modal');
if (!modal) {
alert(HF_DEMO_UNAVAILABLE_MESSAGE);
return;
}
const repoLink = document.getElementById('public-demo-repo-link');
if (repoLink) {
repoLink.href = PUBLIC_DEMO_REPO_URL;
repoLink.textContent = PUBLIC_DEMO_REPO_URL;
}
modal.style.display = 'flex';
}
window.closePublicDemoChatDisabledModal = closePublicDemoChatDisabledModal;
function isHfHostedRuntime() {
try {
const host = String(window.location.hostname || '').toLowerCase();
const path = String(window.location.pathname || '').toLowerCase();
return (
host.endsWith('.hf.space')
|| host.endsWith('.huggingface.co')
|| host === 'huggingface.co'
|| host === 'www.huggingface.co'
|| path.startsWith('/spaces/')
);
} catch (_) {
return false;
}
}
/**
* buildEmptyResponsePlaceholderHtml: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function buildEmptyResponsePlaceholderHtml() {
return `<div class="chat-empty-state">${escapeHtml(EMPTY_RESPONSE_PLACEHOLDER_TEXT)}</div>`;
}
/**
* hasRunnableLocalModel: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function hasRunnableLocalModel() {
return !modelAvailabilityState.loaded || !!modelAvailabilityState.hasAnyRunnableModel;
}
/**
* noLocalModelsMessage: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function noLocalModelsMessage() {
if (String(modelAvailabilityState.inferenceMode || '').toLowerCase() === 'remote') {
return modelAvailabilityState.message || 'Remote MedGemma is unavailable. Check HF secrets and Runtime Debug Log (HF).';
}
return modelAvailabilityState.message || NO_LOCAL_MODELS_MESSAGE;
}
/**
* applyModelAvailabilityToSelects: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function applyModelAvailabilityToSelects() {
const available = Array.isArray(modelAvailabilityState.runnableModels) && modelAvailabilityState.runnableModels.length
? modelAvailabilityState.runnableModels.filter((v) => !!String(v || '').trim())
: (Array.isArray(modelAvailabilityState.availableModels)
? modelAvailabilityState.availableModels.filter((v) => !!String(v || '').trim())
: []);
const hasAny = hasRunnableLocalModel();
const signature = `${modelAvailabilityState.loaded ? '1' : '0'}|${available.slice().sort().join('|')}|${hasAny ? '1' : '0'}|${modelAvailabilityState.inferenceMode || 'local'}`;
if (signature === modelSelectSignature) return;
modelSelectSignature = signature;
const availableSet = new Set(available);
let activeChoices = MODEL_CHOICES.filter((choice) => availableSet.has(choice.value));
if (String(modelAvailabilityState.inferenceMode || '').toLowerCase() === 'remote' && !activeChoices.length) {
activeChoices = MODEL_CHOICES.map((choice) => ({
...choice,
label: String(choice.label || '').replace('(local)', '(remote)'),
}));
}
const selectIds = ['model-select', 'chat-model-select'];
selectIds.forEach((selectId) => {
const select = document.getElementById(selectId);
if (!select) return;
const previous = select.value || '';
select.innerHTML = '';
if (hasAny && activeChoices.length) {
activeChoices.forEach((choice) => {
const opt = document.createElement('option');
opt.value = choice.value;
opt.textContent = choice.label;
select.appendChild(opt);
});
if (activeChoices.some((choice) => choice.value === previous)) {
select.value = previous;
} else {
select.value = activeChoices[0].value;
}
} else {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = String(modelAvailabilityState.inferenceMode || '').toLowerCase() === 'remote'
? 'Remote MedGemma unavailable'
: 'No local MedGemma model installed';
select.appendChild(opt);
select.value = '';
}
});
}
/**
* refreshModelAvailability: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
async function refreshModelAvailability(options = {}) {
const opts = (options && typeof options === 'object') ? options : {};
try {
const res = await fetch('/api/models/availability', { credentials: 'same-origin' });
const payload = await res.json();
if (!res.ok || payload.error) {
throw new Error(payload.error || `Status ${res.status}`);
}
const availableFromPayload = Array.isArray(payload.available_models) ? payload.available_models : [];
const availableFromRows = Array.isArray(payload.models)
? payload.models
.filter((row) => !!(row && row.installed))
.map((row) => String(row.model || '').trim())
.filter((v) => !!v)
: [];
const mergedAvailableModels = availableFromPayload.length ? availableFromPayload : availableFromRows;
const remoteMode = String(payload.mode || '').toLowerCase() === 'remote';
const payloadDisableSubmit = (typeof payload.disable_submit === 'boolean') ? payload.disable_submit : null;
const payloadHasRunnable = (typeof payload.has_any_runnable_model === 'boolean')
? payload.has_any_runnable_model
: (payloadDisableSubmit === null ? null : !payloadDisableSubmit);
const inferredHasRunnable = payloadHasRunnable === null
? (remoteMode ? !!payload.remote_token_set : mergedAvailableModels.length > 0)
: !!payloadHasRunnable;
modelAvailabilityState = {
loaded: true,
hasAnyRunnableModel: inferredHasRunnable,
hasAnyLocalModel: !!payload.has_any_local_model,
runnableModels: Array.isArray(payload.runnable_models)
? payload.runnable_models
: mergedAvailableModels,
availableModels: Array.isArray(payload.available_models) ? payload.available_models : mergedAvailableModels,
missingModels: Array.isArray(payload.missing_models) ? payload.missing_models : [],
inferenceMode: String(payload.inference_mode || 'local'),
remoteTokenSet: !!payload.remote_token_set,
message: (payload.message || '').trim(),
};
applyModelAvailabilityToSelects();
updateUI();
return modelAvailabilityState;
} catch (err) {
// Fail open on transport/API issues: keep UI usable and avoid false disable.
modelAvailabilityState = {
...modelAvailabilityState,
loaded: false,
hasAnyRunnableModel: true,
hasAnyLocalModel: true,
runnableModels: MODEL_CHOICES.map((m) => m.value),
inferenceMode: 'local',
remoteTokenSet: false,
message: '',
};
modelSelectSignature = '';
applyModelAvailabilityToSelects();
updateUI();
if (!opts.silent) {
console.warn('[chat] unable to load model availability', err);
}
return modelAvailabilityState;
}
}
// Model performance tracking: { model: {count, total_ms, avg_ms} }
let chatMetrics = {};
// Hierarchical triage decision tree loaded from server
let triageDecisionTree = null;
const TRIAGE_PATHWAY_FIELD_IDS = [
'triage-domain',
'triage-problem',
'triage-anatomy',
'triage-severity',
'triage-mechanism',
];
const TRIAGE_CONDITION_FIELD_IDS = [
'triage-consciousness',
'triage-breathing',
'triage-circulation',
'triage-overall-stability',
];
const TRIAGE_FIELD_IDS = [
...TRIAGE_CONDITION_FIELD_IDS,
...TRIAGE_PATHWAY_FIELD_IDS,
];
const TRIAGE_FIELD_WRAPPERS = {
'triage-domain': 'triage-domain-field',
'triage-problem': 'triage-problem-field',
'triage-anatomy': 'triage-anatomy-field',
'triage-severity': 'triage-severity-field',
'triage-mechanism': 'triage-mechanism-field',
'triage-consciousness': 'triage-consciousness-field',
'triage-breathing': 'triage-breathing-field',
'triage-circulation': 'triage-circulation-field',
'triage-overall-stability': 'triage-overall-stability-field',
};
let promptRefreshTimer = null;
let blockerCountdownTimer = null;
let triageSupplementDialogShownThisStart = false;
/**
* Load chat performance metrics from server.
*
* Metrics track average response times per model to provide ETA estimates
* during chat processing. Used to show users expected wait times.
*
* Metrics Structure:
* ```javascript
* {
* "google/medgemma-1.5-4b-it": {
* count: 15,
* total_ms: 300000,
* avg_ms: 20000 // ~20 seconds average
* },
* "google/medgemma-1.5-27b-it": {
* count: 3,
* total_ms: 180000,
* avg_ms: 60000 // ~60 seconds average
* }
* }
* ```
*
* Called on page load to initialize ETA estimates.
*/
async function loadChatMetrics() {
try {
const res = await fetch('/api/chat/metrics', { credentials: 'same-origin' });
const data = await res.json();
if (res.ok && data && typeof data.metrics === 'object') {
chatMetrics = data.metrics || {};
}
} catch (err) {
console.warn('[chat] unable to load chat metrics', err);
}
}
/**
* normalizeTriageNodeKey: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function normalizeTriageNodeKey(value) {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
}
/**
* formatTriageOptionLabel: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function formatTriageOptionLabel(value) {
return String(value || '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
}
/**
* lookupTriageNode: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function lookupTriageNode(options, selectedValue) {
if (!options || typeof options !== 'object') return { key: '', node: null };
const selected = String(selectedValue || '').trim();
if (!selected) return { key: '', node: null };
if (Object.prototype.hasOwnProperty.call(options, selected)) {
return { key: selected, node: options[selected] };
}
const wanted = normalizeTriageNodeKey(selected);
if (!wanted) return { key: '', node: null };
const matchKey = Object.keys(options).find((key) => normalizeTriageNodeKey(key) === wanted);
if (!matchKey) return { key: '', node: null };
return { key: matchKey, node: options[matchKey] };
}
/**
* setTriageSelectOptions: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function setTriageSelectOptions(selectId, values, placeholder = 'Select...') {
const select = document.getElementById(selectId);
if (!select) return;
const safeValues = Array.isArray(values) ? values.filter((v) => String(v || '').trim()) : [];
const previous = select.value || '';
select.innerHTML = `<option value="">${placeholder}</option>`;
safeValues.forEach((val) => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = formatTriageOptionLabel(val);
select.appendChild(opt);
});
if (safeValues.includes(previous)) {
select.value = previous;
} else {
select.value = '';
}
}
/**
* setTriageFieldVisibility: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function setTriageFieldVisibility(selectId, visible) {
const wrap = document.getElementById(TRIAGE_FIELD_WRAPPERS[selectId]);
if (!wrap) return;
wrap.style.display = visible ? '' : 'none';
}
/**
* setTriageFieldEnabled: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function setTriageFieldEnabled(selectId, enabled) {
const select = document.getElementById(selectId);
if (!select) return;
select.disabled = !enabled;
}
/**
* sortTriageLabels: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function sortTriageLabels(values) {
return (Array.isArray(values) ? values.slice() : []).sort((a, b) =>
String(a || '').localeCompare(String(b || ''), undefined, { sensitivity: 'base' })
);
}
/**
* syncTriageTreeSelections: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function syncTriageTreeSelections() {
const tree = triageDecisionTree?.tree;
if (!tree || typeof tree !== 'object') return;
const domainKeys = sortTriageLabels(Object.keys(tree));
setTriageSelectOptions('triage-domain', domainKeys);
setTriageFieldVisibility('triage-domain', true);
setTriageFieldEnabled('triage-domain', true);
const domainSelect = document.getElementById('triage-domain');
const domainMatch = lookupTriageNode(tree, domainSelect?.value || '');
if (domainSelect && !domainMatch.key) {
domainSelect.value = '';
}
const domainNode = (domainMatch.node && typeof domainMatch.node === 'object') ? domainMatch.node : {};
const problemMap = (domainNode && typeof domainNode.problems === 'object') ? domainNode.problems : {};
const problemKeys = sortTriageLabels(Object.keys(problemMap));
setTriageSelectOptions('triage-problem', problemKeys);
setTriageFieldVisibility('triage-problem', true);
setTriageFieldEnabled('triage-problem', !!domainMatch.key);
const problemSelect = document.getElementById('triage-problem');
const problemMatch = lookupTriageNode(problemMap, problemSelect?.value || '');
if (problemSelect && !problemMatch.key) {
problemSelect.value = '';
}
const problemNode = (problemMatch.node && typeof problemMatch.node === 'object') ? problemMatch.node : {};
const anatomyMap = (problemNode && typeof problemNode.anatomy_guardrails === 'object') ? problemNode.anatomy_guardrails : {};
const severityMap = (problemNode && typeof problemNode.severity_modifiers === 'object') ? problemNode.severity_modifiers : {};
const mechanismMap = (problemNode && typeof problemNode.mechanism_modifiers === 'object') ? problemNode.mechanism_modifiers : {};
const anatomyKeys = sortTriageLabels(Object.keys(anatomyMap));
const severityKeys = sortTriageLabels(Object.keys(severityMap));
const mechanismKeys = sortTriageLabels(Object.keys(mechanismMap));
setTriageSelectOptions('triage-anatomy', anatomyKeys, 'Select...');
setTriageSelectOptions('triage-severity', severityKeys, 'Select...');
setTriageSelectOptions('triage-mechanism', mechanismKeys, 'Select...');
const anatomyVisible = anatomyKeys.length > 0;
const severityVisible = severityKeys.length > 0;
const mechanismVisible = mechanismKeys.length > 0;
setTriageFieldVisibility('triage-anatomy', true);
setTriageFieldVisibility('triage-severity', true);
setTriageFieldVisibility('triage-mechanism', true);
const anatomySelect = document.getElementById('triage-anatomy');
const severitySelect = document.getElementById('triage-severity');
const mechanismSelect = document.getElementById('triage-mechanism');
if (!anatomyVisible && anatomySelect) anatomySelect.value = '';
if (!severityVisible && severitySelect) severitySelect.value = '';
if (!mechanismVisible && mechanismSelect) mechanismSelect.value = '';
const canChooseProblem = !!domainMatch.key;
if (!canChooseProblem && problemSelect) {
problemSelect.value = '';
}
setTriageFieldEnabled('triage-problem', canChooseProblem);
const canChooseAnatomy = !!domainMatch.key && !!problemMatch.key && anatomyVisible;
if (!canChooseAnatomy && anatomySelect) {
anatomySelect.value = '';
}
setTriageFieldEnabled('triage-anatomy', canChooseAnatomy);
const anatomySatisfied = !anatomyVisible || !!(anatomySelect?.value || '');
const canChooseSeverity = !!domainMatch.key && !!problemMatch.key && severityVisible && anatomySatisfied;
if (!canChooseSeverity && severitySelect) {
severitySelect.value = '';
}
setTriageFieldEnabled('triage-severity', canChooseSeverity);
const severitySatisfied = !severityVisible || !!(severitySelect?.value || '');
const canChooseMechanism = !!domainMatch.key && !!problemMatch.key && mechanismVisible && anatomySatisfied && severitySatisfied;
if (!canChooseMechanism && mechanismSelect) {
mechanismSelect.value = '';
}
setTriageFieldEnabled('triage-mechanism', canChooseMechanism);
}
async function loadTriageDecisionTree(force = false) {
if (triageDecisionTree && !force) return triageDecisionTree;
try {
const res = await fetch('/api/triage/tree', { credentials: 'same-origin', cache: 'no-store' });
if (!res.ok) throw new Error(`Status ${res.status}`);
const payload = await res.json();
if (!payload || typeof payload !== 'object' || typeof payload.tree !== 'object') {
throw new Error('Invalid tree payload');
}
triageDecisionTree = payload;
} catch (err) {
console.warn('[chat] unable to load triage tree', err);
triageDecisionTree = null;
}
syncTriageTreeSelections();
return triageDecisionTree;
}
/**
* schedulePromptRefresh: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function schedulePromptRefresh(delayMs = 140) {
if (promptRefreshTimer) {
clearTimeout(promptRefreshTimer);
}
promptRefreshTimer = setTimeout(() => {
promptRefreshTimer = null;
refreshPromptPreview();
}, delayMs);
}
/**
* clearBlockerCountdown: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function clearBlockerCountdown() {
if (blockerCountdownTimer) {
clearInterval(blockerCountdownTimer);
blockerCountdownTimer = null;
}
}
/**
* Initialize the prompt preview/editor panel.
*
* Setup Process:
* 1. Binds click/keyboard handlers to toggle expansion
* 2. Tracks manual edits to prevent auto-refresh overwrites
* 3. Restores previous state (expanded/collapsed) from localStorage
* 4. Pre-populates with default prompt
* 5. Binds input handlers to all fields for state persistence
*
* The prompt editor allows users to:
* - View the exact prompt being sent to the AI model
* - Customize the prompt for specific needs
* - See how patient context is injected
* - Edit system instructions or constraints
*
* Auto-fill Logic:
* - If user has manually edited (dataset.autofilled = 'false'), preserve edits
* - If no manual edits, auto-refresh when patient/mode/fields change
*
* Called on DOMContentLoaded to ensure all elements exist.
*/
function setupPromptInjectionPanel() {
const promptHeader = document.getElementById('prompt-preview-header');
const promptBox = document.getElementById('prompt-preview');
if (promptHeader && !promptHeader.dataset.bound) {
promptHeader.dataset.bound = 'true';
promptHeader.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
togglePromptPreviewArrow(promptHeader);
});
promptHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePromptPreviewArrow(promptHeader);
}
});
}
if (promptBox && !promptBox.dataset.inputBound) {
promptBox.dataset.inputBound = 'true';
promptBox.addEventListener('input', () => {
promptBox.dataset.autofilled = 'false';
});
}
const msgTextarea = document.getElementById('msg');
if (msgTextarea && !msgTextarea.dataset.stateBound) {
msgTextarea.dataset.stateBound = 'true';
msgTextarea.addEventListener('input', () => {
persistChatState();
schedulePromptRefresh();
});
}
if (promptHeader) {
let shouldOpen = false;
try {
shouldOpen = localStorage.getItem(PROMPT_PREVIEW_STATE_KEY) === 'true';
} catch (err) { /* ignore */ }
togglePromptPreviewArrow(promptHeader, shouldOpen);
}
if (promptBox && promptBox.dataset.autofilled !== 'false' && (!promptBox.value || !promptBox.value.trim())) {
refreshPromptPreview();
}
}
if (document.readyState !== 'loading') {
setupPromptInjectionPanel();
bindTriageMetaRefresh();
loadTriageDecisionTree().catch(() => {});
applyChatState(currentMode);
loadChatMetrics().catch(() => {});
} else {
document.addEventListener('DOMContentLoaded', setupPromptInjectionPanel, { once: true });
document.addEventListener('DOMContentLoaded', bindTriageMetaRefresh, { once: true });
document.addEventListener('DOMContentLoaded', () => loadTriageDecisionTree().catch(() => {}), { once: true });
document.addEventListener('DOMContentLoaded', () => applyChatState(currentMode), { once: true });
document.addEventListener('DOMContentLoaded', () => loadChatMetrics().catch(() => {}), { once: true });
}
document.addEventListener('DOMContentLoaded', () => {
try {
const stored = localStorage.getItem(LOGGING_MODE_KEY);
if (stored === '1') {
isPrivate = true;
}
} catch (err) { /* ignore */ }
const btn = document.getElementById('priv-btn');
if (btn) {
btn.style.background = isPrivate ? 'var(--triage)' : '#333';
btn.style.border = isPrivate ? '2px solid #fff' : '1px solid #222';
btn.innerText = isPrivate ? 'LOGGING: OFF' : 'LOGGING: ON';
}
const blocker = document.getElementById('chat-blocker');
if (blocker) blocker.classList.remove('active');
});
/**
* isSessionActive: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function isSessionActive(modeOverride = currentMode) {
const modeSession = getModeSession(modeOverride);
return !!modeSession.sessionId;
}
/**
* isAdvancedMode: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function isAdvancedMode() {
return document.body.classList.contains('mode-advanced') || document.body.classList.contains('mode-developer');
}
/**
* getStartPanelExpanded: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function getStartPanelExpanded() {
const body = document.getElementById('query-form-body');
if (!body) return false;
return body.style.display === '' || body.style.display === 'block';
}
/**
* setStartPanelExpanded: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function setStartPanelExpanded(expanded) {
const header = document.getElementById('query-form-header');
const body = document.getElementById('query-form-body');
if (!header || !body) return;
const nextExpanded = !!expanded;
body.style.display = nextExpanded ? 'block' : 'none';
const icon = header.querySelector('.detail-icon');
if (icon) icon.textContent = nextExpanded ? '▾' : '▸';
if (header.dataset?.prefKey) {
try { localStorage.setItem(header.dataset.prefKey, nextExpanded.toString()); } catch (err) { /* ignore */ }
}
updateStartPanelTitle();
}
/**
* updateStartPanelTitle: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function updateStartPanelTitle() {
const queryTitle = document.getElementById('query-form-title');
if (!queryTitle) return;
const expanded = getStartPanelExpanded();
const modeLabel = currentMode === 'triage' ? 'Triage' : 'Inquiry';
queryTitle.innerText = expanded
? `Start New ${modeLabel} Consultation`
: `Expand to Start a New ${modeLabel} Consultation`;
}
/**
* resetStartForm: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function resetStartForm() {
const msg = document.getElementById('msg');
if (msg) msg.value = '';
const patientSelect = document.getElementById('p-select');
if (patientSelect) patientSelect.value = '';
const ids = [
...TRIAGE_FIELD_IDS,
];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = '';
});
try { localStorage.removeItem(LAST_PATIENT_KEY); } catch (err) { /* ignore */ }
persistChatState();
resetPromptPreviewToDefault();
}
/**
* resetConsultationUiForDemo: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function resetConsultationUiForDemo() {
if (promptRefreshTimer) {
clearTimeout(promptRefreshTimer);
promptRefreshTimer = null;
}
clearBlockerCountdown();
isProcessing = false;
triageSupplementDialogShownThisStart = false;
Object.assign(modeSessions.triage, createEmptyModeSession());
Object.assign(modeSessions.inquiry, createEmptyModeSession());
currentMode = 'triage';
const display = document.getElementById('display');
if (display) display.innerHTML = '';
renderTranscript([]);
const chatInput = document.getElementById('chat-input');
if (chatInput) chatInput.value = '';
const msgInput = document.getElementById('msg');
if (msgInput) msgInput.value = '';
const patientSelect = document.getElementById('p-select');
if (patientSelect) patientSelect.value = '';
TRIAGE_FIELD_IDS.forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = '';
});
syncTriageTreeSelections();
const modelSelect = document.getElementById('model-select');
if (modelSelect && modelSelect.options.length) {
modelSelect.selectedIndex = 0;
}
const chatModelSelect = document.getElementById('chat-model-select');
if (chatModelSelect) {
if (modelSelect && modelSelect.value) {
chatModelSelect.value = modelSelect.value;
} else if (chatModelSelect.options.length) {
chatModelSelect.selectedIndex = 0;
}
}
const modeSelect = document.getElementById('mode-select');
if (modeSelect) modeSelect.value = 'triage';
const promptBox = document.getElementById('prompt-preview');
if (promptBox) {
promptBox.value = '';
promptBox.dataset.autofilled = 'true';
}
const promptHeader = document.getElementById('prompt-preview-header');
const promptContainer = document.getElementById('prompt-preview-container');
const promptInline = document.getElementById('prompt-refresh-inline');
if (promptHeader) {
promptHeader.setAttribute('aria-expanded', 'false');
const icon = promptHeader.querySelector('.detail-icon');
if (icon) icon.textContent = '▸';
}
if (promptContainer) promptContainer.style.display = 'none';
if (promptInline) promptInline.style.display = 'none';
isPrivate = false;
try {
localStorage.removeItem(LAST_PROMPT_KEY);
localStorage.removeItem(LAST_PATIENT_KEY);
localStorage.removeItem(LAST_CHAT_MODE_KEY);
localStorage.removeItem(CHAT_STATE_KEY);
localStorage.removeItem(PROMPT_PREVIEW_CONTENT_KEY);
localStorage.removeItem(PROMPT_PREVIEW_STATE_KEY);
localStorage.setItem(LOGGING_MODE_KEY, '0');
localStorage.setItem(SKIP_LAST_CHAT_KEY, '1');
} catch (err) { /* ignore */ }
setStartPanelExpanded(true);
updateUI();
syncStartPanelWithConsultationState();
}
/**
* hasPreviousConsultationResponseOnScreen: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function hasPreviousConsultationResponseOnScreen(modeOverride = currentMode) {
if (isSessionActive(modeOverride)) return true;
const display = document.getElementById('display');
if (!display) return false;
const hasStructuredContent = !!display.querySelector('.chat-message, .response-block');
if (!hasStructuredContent) return false;
const text = (display.textContent || '').trim();
if (!text) return false;
return text !== EMPTY_RESPONSE_PLACEHOLDER_TEXT;
}
/**
* restorePromptDefaultsForCurrentMode: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function restorePromptDefaultsForCurrentMode() {
const modeSession = getModeSession(currentMode);
modeSession.promptBase = '';
resetPromptPreviewToDefault();
}
/**
* clearConsultationForNewStart: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function clearConsultationForNewStart({ setSkipLastChat = true } = {}) {
if (isSessionActive(currentMode)) {
endActiveSession({ clearDisplay: true, mode: currentMode });
} else {
const modeSession = getModeSession(currentMode);
modeSession.sessionId = null;
modeSession.sessionMeta = null;
modeSession.transcript = [];
modeSession.promptBase = '';
syncCurrentModeTranscriptView();
const chatInput = document.getElementById('chat-input');
if (chatInput) chatInput.value = '';
}
resetStartForm();
if (setSkipLastChat) {
try { localStorage.setItem(SKIP_LAST_CHAT_KEY, '1'); } catch (err) { /* ignore */ }
}
}
/**
* handleStartPanelToggle: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function handleStartPanelToggle(expanded) {
const isExpanded = !!expanded;
if (!isExpanded) {
updateStartPanelTitle();
return;
}
const hadPrevious = hasPreviousConsultationResponseOnScreen(currentMode);
if (hadPrevious) {
clearConsultationForNewStart({ setSkipLastChat: true });
const msg = isPrivate
? 'Previous consultation response was cleared from the screen. Logging is OFF, so it was not saved to the Consultation Log.'
: 'Previous consultation response was saved to the Consultation Log and cleared from the screen.';
alert(msg);
}
restorePromptDefaultsForCurrentMode();
updateStartPanelTitle();
}
/**
* syncStartPanelWithConsultationState: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function syncStartPanelWithConsultationState() {
const hasPrevious = hasPreviousConsultationResponseOnScreen(currentMode);
setStartPanelExpanded(!hasPrevious);
}
/**
* syncCurrentModeTranscriptView: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function syncCurrentModeTranscriptView() {
const modeSession = getModeSession(currentMode);
if (isSessionActive(currentMode)) {
renderTranscript(modeSession.transcript || []);
} else {
renderTranscript([]);
}
}
/**
* endActiveSession: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function endActiveSession({ clearDisplay = true, mode = currentMode } = {}) {
const modeSession = getModeSession(mode);
modeSession.sessionId = null;
modeSession.sessionMeta = null;
modeSession.transcript = [];
modeSession.promptBase = '';
if (clearDisplay && normalizeMode(mode) === normalizeMode(currentMode)) {
syncCurrentModeTranscriptView();
}
if (normalizeMode(mode) === normalizeMode(currentMode)) {
const chatInput = document.getElementById('chat-input');
if (chatInput) chatInput.value = '';
}
updateUI();
}
/**
* capturePromptBaseForSession: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function capturePromptBaseForSession() {
if (!isAdvancedMode()) return '';
const promptBox = document.getElementById('prompt-preview');
if (!promptBox) return '';
if (promptBox.dataset.autofilled === 'false' && promptBox.value.trim()) {
return cleanPromptWhitespace(promptBox.value);
}
return '';
}
/**
* setStartFormDisabled: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function setStartFormDisabled(disabled) {
const ids = [
'p-select',
'model-select',
'msg',
...TRIAGE_FIELD_IDS,
'priv-btn',
'prompt-preview',
];
ids.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
if (disabled) {
el.setAttribute('disabled', 'disabled');
} else {
el.removeAttribute('disabled');
}
});
}
/**
* syncModelSelects: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function syncModelSelects(sourceId) {
const primary = document.getElementById('model-select');
const chatModel = document.getElementById('chat-model-select');
if (!primary || !chatModel) return;
if (sourceId === 'model-select') {
chatModel.value = primary.value;
} else if (sourceId === 'chat-model-select') {
primary.value = chatModel.value;
} else if (!chatModel.value && primary.value) {
chatModel.value = primary.value;
} else if (!primary.value && chatModel.value) {
primary.value = chatModel.value;
}
}
/**
* updateChatComposerVisibility: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function updateChatComposerVisibility() {
const composer = document.getElementById('chat-composer');
if (!composer) return;
composer.style.display = isSessionActive() ? 'flex' : 'none';
}
/**
* Update all UI elements to reflect current mode and privacy state.
*
* Visual Changes:
* - Banner colors (red for triage, green for inquiry)
* - Button colors and text
* - Field visibility (triage metadata shown/hidden)
* - Placeholder text
* - Privacy indicator styling
*
* Called After:
* - Mode changes (triage ↔ inquiry)
* - Privacy toggle
* - Page initialization
*
* Mode-Specific UI:
* - TRIAGE: Red theme, structured fields visible, "SUBMIT FOR TRIAGE" button
* - INQUIRY: Green theme, fields hidden, "SUBMIT INQUIRY" button
*
* Privacy Indicator:
* - LOGGING ON: Gray background, normal border
* - LOGGING OFF: Red background, white border, prominent warning
*/
function updateUI() {
const banner = document.getElementById('banner');
const modeSelect = document.getElementById('mode-select');
const privBtn = document.getElementById('priv-btn');
const msg = document.getElementById('msg');
const runBtn = document.getElementById('run-btn');
const modelSelect = document.getElementById('model-select');
const sendBtn = document.getElementById('chat-send-btn');
const chatModelSelect = document.getElementById('chat-model-select');
const startModelWarning = document.getElementById('model-availability-warning');
const chatModelWarning = document.getElementById('chat-model-availability-warning');
const promptRefreshInline = document.getElementById('prompt-refresh-inline');
const promptHeader = document.getElementById('prompt-preview-header');
const demoChatBlocked = isPublicDemoChatDisabled();
const localModelsAvailable = hasRunnableLocalModel();
const noModels = !localModelsAvailable;
const noModelsMsg = demoChatBlocked ? HF_DEMO_UNAVAILABLE_MESSAGE : noLocalModelsMessage();
// Remove both classes first
banner.classList.remove('inquiry-mode', 'private-mode', 'no-privacy');
// Add appropriate mode class
if (currentMode === 'inquiry') {
banner.classList.add('inquiry-mode');
}
// Privacy visuals
if (isPrivate) {
banner.classList.add('private-mode');
} else {
banner.classList.add('no-privacy');
}
if (modeSelect) {
modeSelect.value = currentMode;
modeSelect.style.background = currentMode === 'triage' ? '#ffecec' : '#e6f5ec';
modeSelect.disabled = false;
}
const triageConditionContainer = document.getElementById('triage-condition-container');
if (triageConditionContainer) {
triageConditionContainer.style.display = currentMode === 'triage' ? 'block' : 'none';
}
const triagePathwayContainer = document.getElementById('triage-pathway-container');
if (triagePathwayContainer) {
triagePathwayContainer.style.display = currentMode === 'triage' ? 'block' : 'none';
}
const triageMeta = document.getElementById('triage-meta-selects');
if (triageMeta) {
triageMeta.style.display = currentMode === 'triage' ? 'grid' : 'none';
}
updateStartPanelTitle();
if (privBtn) {
privBtn.classList.toggle('is-private', isPrivate);
privBtn.innerText = isPrivate ? 'LOGGING: OFF' : 'LOGGING: ON';
privBtn.style.background = isPrivate ? 'var(--triage)' : '#333';
privBtn.style.border = isPrivate ? '2px solid #fff' : '1px solid #222';
}
if (msg) {
msg.placeholder = currentMode === 'triage' ? "What is the situation?" : "Ask your question";
}
if (runBtn) {
runBtn.innerText = currentMode === 'triage'
? 'Submit to Start Triage Consultation'
: 'Submit to Start Inquiry Consultation';
runBtn.style.background = currentMode === 'triage' ? 'var(--triage)' : 'var(--inquiry)';
runBtn.title = noModels ? noModelsMsg : '';
}
if (promptRefreshInline) {
const isExpanded = promptHeader && promptHeader.getAttribute('aria-expanded') === 'true';
const isAdvanced = document.body.classList.contains('mode-advanced') || document.body.classList.contains('mode-developer');
promptRefreshInline.style.display = (isExpanded && isAdvanced) ? 'flex' : 'none';
}
// Show inline refresh only when prompt section is expanded (handled in toggle)
if (promptRefreshInline) {
const promptHeader = document.getElementById('prompt-preview-header');
const isExpanded = promptHeader && promptHeader.getAttribute('aria-expanded') === 'true';
promptRefreshInline.style.display = isExpanded ? 'flex' : 'none';
}
applyModelAvailabilityToSelects();
// Default model to the first available option on load.
if (modelSelect && !modelSelect.value && modelSelect.options.length) {
modelSelect.value = modelSelect.options[0].value;
}
syncModelSelects();
updateChatComposerVisibility();
setStartFormDisabled(isSessionActive());
if (modelSelect) {
const sessionLocked = isSessionActive();
if (noModels) {
modelSelect.setAttribute('disabled', 'disabled');
} else if (!sessionLocked) {
modelSelect.removeAttribute('disabled');
}
}
if (chatModelSelect) {
if (noModels) {
chatModelSelect.setAttribute('disabled', 'disabled');
} else {
chatModelSelect.removeAttribute('disabled');
}
}
if (runBtn) {
// Keep submit clickable in hosted demo mode so we can show the explanatory popup.
runBtn.disabled = isSessionActive() || (noModels && !demoChatBlocked);
}
if (sendBtn) {
sendBtn.disabled = !isSessionActive() || (noModels && !demoChatBlocked);
sendBtn.title = noModels ? noModelsMsg : '';
}
if (startModelWarning) {
startModelWarning.textContent = noModelsMsg;
startModelWarning.style.display = noModels ? 'block' : 'none';
}
if (chatModelWarning) {
chatModelWarning.textContent = noModelsMsg;
chatModelWarning.style.display = noModels ? 'block' : 'none';
}
}
/**
* Bind event listeners to triage metadata fields for prompt refresh.
*
* When any triage field changes:
* 1. Persists the change to localStorage (chat state)
* 2. Refreshes prompt preview (if expanded and auto-fill enabled)
*
* Uses dataset flag (tmodeBound) to prevent duplicate binding.
*
* Fields Monitored:
* - Consciousness
* - Breathing
* - Circulation
* - Overall Stability
* - Domain
* - Problem / Injury Type
* - Anatomy
* - Severity / Complication
* - Mechanism / Cause
*/
function bindTriageMetaRefresh() {
const ids = [...TRIAGE_FIELD_IDS];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el && !el.dataset.tmodeBound) {
el.dataset.tmodeBound = 'true';
el.addEventListener('change', () => {
if (TRIAGE_PATHWAY_FIELD_IDS.includes(id)) {
syncTriageTreeSelections();
}
persistChatState();
schedulePromptRefresh(0);
});
}
});
}
/**
* Load persisted chat state from localStorage.
*
* Chat state is stored per-mode to allow switching between triage and
* inquiry without losing unsaved work in either mode.
*
* State Structure:
* ```javascript
* {
* triage: {
* msg: "Patient fell from mast...",
* fields: {
* "triage-domain": "Trauma",
* "triage-problem": "Fracture",
* "triage-anatomy": "Leg / Foot",
* // ... other triage fields
* }
* },
* inquiry: {
* msg: "What antibiotics treat UTI?"
* }
* }
* ```
*
* @returns {Object} Chat state object or empty object if not found
*/
function loadChatState() {
try {
const raw = localStorage.getItem(CHAT_STATE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return typeof parsed === 'object' && parsed ? parsed : {};
} catch (err) {
return {};
}
}
/**
* Persist current chat state to localStorage.
*
* Saves both the main message and mode-specific fields (triage metadata)
* separately per mode, allowing seamless mode switching without data loss.
*
* Called After:
* - User types in message field
* - User changes triage dropdown
* - Mode switch (to save state before switching)
*
* @param {string} modeOverride - Optional mode to save state for (default: current mode)
*/
function persistChatState(modeOverride) {
const mode = modeOverride || currentMode;
const state = loadChatState();
const msgEl = document.getElementById('msg');
const patientEl = document.getElementById('p-select');
const modelEl = document.getElementById('model-select');
const chatModelEl = document.getElementById('chat-model-select');
state[mode] = state[mode] || {};
state[mode].msg = msgEl ? msgEl.value : '';
state[mode].patient = patientEl ? patientEl.value : '';
state[mode].start_model = modelEl ? modelEl.value : '';
state[mode].chat_model = chatModelEl ? chatModelEl.value : '';
state[mode].is_private = !!isPrivate;
if (mode === 'triage') {
const ids = [...TRIAGE_FIELD_IDS];
state[mode].fields = {};
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) state[mode].fields[id] = el.value || '';
});
}
try { localStorage.setItem(CHAT_STATE_KEY, JSON.stringify(state)); } catch (err) { /* ignore */ }
}
/**
* Restore chat state from localStorage to UI fields.
*
* Called when:
* - Switching modes (restore previously entered data for that mode)
* - Page load (restore work in progress)
*
* Only restores fields that exist in saved state to avoid overwriting
* with empty values.
*
* @param {string} modeOverride - Optional mode to restore state from (default: current mode)
*/
function applyChatState(modeOverride) {
const mode = modeOverride || currentMode;
const state = loadChatState();
const modeState = state[mode] || {};
const msgEl = document.getElementById('msg');
if (msgEl) {
msgEl.value = typeof modeState.msg === 'string' ? modeState.msg : '';
}
if (mode === 'triage') {
TRIAGE_FIELD_IDS.forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = '';
});
syncTriageTreeSelections();
if (modeState.fields && typeof modeState.fields === 'object') {
const orderedIds = [
'triage-consciousness',
'triage-breathing',
'triage-circulation',
'triage-overall-stability',
'triage-domain',
'triage-problem',
'triage-anatomy',
'triage-severity',
'triage-mechanism',
];
orderedIds.forEach((id) => {
const el = document.getElementById(id);
if (el && typeof modeState.fields[id] === 'string') {
el.value = modeState.fields[id];
syncTriageTreeSelections();
}
});
}
}
const patientEl = document.getElementById('p-select');
if (patientEl && typeof modeState.patient === 'string') {
const hasValue = Array.from(patientEl.options).some((o) => o.value === modeState.patient);
if (hasValue) {
patientEl.value = modeState.patient;
}
}
const modelEl = document.getElementById('model-select');
if (modelEl && typeof modeState.start_model === 'string') {
const hasValue = Array.from(modelEl.options).some((o) => o.value === modeState.start_model);
if (hasValue) {
modelEl.value = modeState.start_model;
}
}
const chatModelEl = document.getElementById('chat-model-select');
if (chatModelEl && typeof modeState.chat_model === 'string') {
const hasValue = Array.from(chatModelEl.options).some((o) => o.value === modeState.chat_model);
if (hasValue) {
chatModelEl.value = modeState.chat_model;
}
}
if (typeof modeState.is_private === 'boolean') {
isPrivate = modeState.is_private;
try { localStorage.setItem(LOGGING_MODE_KEY, isPrivate ? '1' : '0'); } catch (err) { /* ignore */ }
}
syncModelSelects();
}
/**
* Toggle privacy/logging mode.
*
* Privacy Modes:
* - LOGGING ON (isPrivate=false): All chats saved to history.json with crew association
* - LOGGING OFF (isPrivate=true): Ephemeral mode, no database persistence
*
* Use Cases:
* - LOGGING OFF: Sensitive consultations, practice scenarios, testing
* - LOGGING ON: Normal operations, building medical history records
*
* Visual Feedback:
* - Button color changes (gray → red)
* - Border emphasis (solid → white outline)
* - Text updates ("LOGGING: ON" ↔ "LOGGING: OFF")
* - Banner styling changes
*
* State persisted to localStorage for consistency across sessions.
*/
function togglePriv() {
if (isSessionActive()) {
alert('Logging is locked for the current session. Start a new consultation to change it.');
return;
}
isPrivate = !isPrivate;
const btn = document.getElementById('priv-btn');
btn.style.background = isPrivate ? 'var(--triage)' : '#333';
btn.style.border = isPrivate ? '2px solid #fff' : '1px solid #222';
btn.innerText = isPrivate ? 'LOGGING: OFF' : 'LOGGING: ON';
try { localStorage.setItem(LOGGING_MODE_KEY, isPrivate ? '1' : '0'); } catch (err) { /* ignore */ }
persistChatState(currentMode);
updateUI();
}
/**
* Execute chat submission to AI model.
*
* Processing Flow:
* 1. Validate input and check processing lock
* 2. Show loading blocker with ETA estimate
* 3. Collect all form data (message, patient, mode, triage fields)
* 4. Check for custom prompt override
* 5. Submit to /api/chat endpoint
* 6. Handle 28B model confirmation if needed
* 7. Parse and display Markdown response
* 8. Update metrics and refresh history
* 9. Persist state and unlock UI
*
* Model Confirmation:
* 27B model requires explicit confirmation due to long processing time
* (potentially 60+ seconds on CPU). Shows confirm dialog before proceeding.
*
* Prompt Override:
* If prompt editor is expanded and contains custom text, sends modified
* prompt instead of default system prompt. Allows advanced users to
* customize AI instructions.
*
* Response Handling:
* - Parses Markdown with marked.js (if available)
* - Falls back to <br> replacement
* - Shows model name and duration
* - Displays errors with red border
* - Scrolls to show new response
*
* Side Effects:
* - Saves chat to history.json (unless logging disabled)
* - Updates crew member's medical log
* - Refreshes chat metrics
* - Triggers lightweight loadData({skipPatients:true}) refresh for crew history UI
*
* @param {string} promptText - Optional override for message text
* @param {boolean} force28b - Skip 28B confirmation (after user confirms)
*/
function buildSessionMetaPayload({ initialQuery, patientId, patientName, mode }) {
const modeKey = normalizeMode(mode || currentMode);
const modeSession = getModeSession(modeKey);
const base = modeSession.sessionMeta || {};
const startedAt = base.started_at || base.date || new Date().toISOString().slice(0, 16).replace('T', ' ');
const meta = {
session_id: modeSession.sessionId || base.session_id,
mode: modeKey || base.mode || currentMode,
patient_id: patientId ?? base.patient_id,
patient: patientName ?? base.patient,
initial_query: base.initial_query || initialQuery,
started_at: startedAt,
};
if (modeSession.promptBase) meta.prompt_base = modeSession.promptBase;
if (base.triage_path && typeof base.triage_path === 'object') {
meta.triage_path = base.triage_path;
} else if (modeKey === 'triage') {
const path = {
domain: document.getElementById('triage-domain')?.value || '',
problem: document.getElementById('triage-problem')?.value || '',
anatomy: document.getElementById('triage-anatomy')?.value || '',
severity: document.getElementById('triage-severity')?.value || '',
mechanism: document.getElementById('triage-mechanism')?.value || '',
};
const filtered = Object.fromEntries(Object.entries(path).filter(([, v]) => !!String(v || '').trim()));
if (Object.keys(filtered).length) {
meta.triage_path = filtered;
}
}
return meta;
}
async function submitChatMessage({ message, isStart, force28b = false, queueWait = false }) {
if (isPublicDemoChatDisabled()) {
showPublicDemoChatDisabledModal();
return;
}
const txt = (message || '').trim();
if (!txt || isProcessing) return;
if (!hasRunnableLocalModel()) {
alert(noLocalModelsMessage());
updateUI();
return;
}
isProcessing = true;
if (typeof window.flushSettingsBeforeChat === 'function') {
try {
const synced = await window.flushSettingsBeforeChat();
if (!synced) {
alert('Unable to save Settings values before consultation. Please review Settings and try again.');
isProcessing = false;
return;
}
} catch (err) {
alert(`Unable to sync Settings before consultation: ${err.message || err}`);
isProcessing = false;
return;
}
}
lastPrompt = txt;
const mode = normalizeMode(isStart ? currentMode : currentMode);
const modeSession = getModeSession(mode);
const modelSelectId = isStart ? 'model-select' : 'chat-model-select';
const modelName = document.getElementById(modelSelectId)?.value || 'google/medgemma-1.5-4b-it';
const blocker = document.getElementById('chat-blocker');
if (blocker) {
clearBlockerCountdown();
const title = blocker.querySelector('h3');
if (title) {
if (queueWait) {
title.textContent = 'Model Busy - Waiting in Queue…';
} else {
title.textContent = mode === 'triage' ? 'Processing Triage Chat…' : 'Processing Inquiry Chat…';
}
}
const modelLine = document.getElementById('chat-model-line');
const etaLine = document.getElementById('chat-eta-line');
if (modelLine) modelLine.textContent = `Model: ${modelName}`;
const avgMs = (chatMetrics[modelName]?.avg_ms) || (modelName.toLowerCase().includes('27b') ? 60000 : 20000);
if (etaLine) {
const expectedSeconds = Math.max(1, Math.round(avgMs / 1000));
let remainingSeconds = expectedSeconds;
if (queueWait) {
etaLine.textContent = `Waiting for active consultation to finish. Expected run duration after start: ~${expectedSeconds}s`;
} else {
etaLine.textContent = `Expected duration: ~${expectedSeconds}s • Remaining: ${remainingSeconds}s`;
}
blockerCountdownTimer = setInterval(() => {
remainingSeconds = Math.max(0, remainingSeconds - 1);
const remainingLabel = remainingSeconds > 0 ? `${remainingSeconds}s` : '<1s';
if (queueWait) {
etaLine.textContent = `Waiting for active consultation to finish. Expected run duration after start: ~${expectedSeconds}s`;
} else {
etaLine.textContent = `Expected duration: ~${expectedSeconds}s • Remaining: ${remainingLabel}`;
}
}, 1000);
}
blocker.classList.add('active');
}
const display = document.getElementById('display');
const loadingDiv = document.createElement('div');
loadingDiv.id = 'loading-indicator';
loadingDiv.className = 'loading-indicator';
loadingDiv.innerHTML = '🔄 Analyzing...';
if (display) {
display.appendChild(loadingDiv);
display.scrollTop = display.scrollHeight;
}
document.getElementById('run-btn').disabled = true;
const sendBtn = document.getElementById('chat-send-btn');
if (sendBtn) sendBtn.disabled = true;
try {
const fd = new FormData();
fd.append('message', txt);
const patientVal = document.getElementById('p-select')?.value || '';
const patientName = document.getElementById('p-select')?.selectedOptions?.[0]?.textContent || '';
if (isStart) {
try { localStorage.setItem(LAST_PATIENT_KEY, patientVal); } catch (err) { /* ignore */ }
fd.append('patient', patientVal);
} else {
fd.append('patient', modeSession.sessionMeta?.patient_id || modeSession.sessionMeta?.patient || patientVal || '');
}
fd.append('mode', mode);
fd.append('private', isPrivate ? 'true' : 'false');
fd.append('model_choice', modelName);
fd.append('force_28b', force28b ? 'true' : 'false');
fd.append('queue_wait', queueWait ? 'true' : 'false');
if (isStart && mode === 'triage') {
fd.append('triage_consciousness', document.getElementById('triage-consciousness')?.value || '');
fd.append('triage_breathing', document.getElementById('triage-breathing')?.value || '');
fd.append('triage_circulation', document.getElementById('triage-circulation')?.value || '');
fd.append('triage_overall_stability', document.getElementById('triage-overall-stability')?.value || '');
fd.append('triage_domain', document.getElementById('triage-domain')?.value || '');
fd.append('triage_problem', document.getElementById('triage-problem')?.value || '');
fd.append('triage_anatomy', document.getElementById('triage-anatomy')?.value || '');
fd.append('triage_severity', document.getElementById('triage-severity')?.value || '');
fd.append('triage_mechanism', document.getElementById('triage-mechanism')?.value || '');
}
fd.append('session_action', isStart ? 'start' : 'message');
if (!isStart && modeSession.sessionId) {
fd.append('session_id', modeSession.sessionId);
}
if (!isStart && modeSession.transcript.length) {
fd.append('transcript', JSON.stringify(modeSession.transcript));
}
const metaPayload = buildSessionMetaPayload({
initialQuery: txt,
patientId: patientVal,
patientName,
mode,
});
fd.append('session_meta', JSON.stringify(metaPayload));
const triageMeta = isStart && mode === 'triage' ? collectTriageMeta() : null;
if (modeSession.promptBase) {
const overridePrompt = buildSessionOverridePrompt(modeSession.promptBase, modeSession.transcript, txt, triageMeta, mode);
if (overridePrompt) {
fd.append('override_prompt', overridePrompt);
}
}
const response = await fetch('/api/chat', { method: 'POST', body: fd, credentials: 'same-origin' });
const res = await response.json();
if (
isStart
&& mode === 'triage'
&& res?.triage_pathway_supplemented
&& !triageSupplementDialogShownThisStart
) {
alert(
'Clinical Triage Pathway is not fully defined for the selected choices. '
+ 'Your selection(s) will be supplemented with the general triage prompt from Settings.'
);
triageSupplementDialogShownThisStart = true;
}
loadingDiv.remove();
if (res.gpu_busy) {
if (res.queue_prompt) {
const waitChoice = confirm(
`${res.error || 'Another user is currently running a model.'}\n\nPress OK to wait in queue.\nPress Cancel to try again later.`
);
if (waitChoice) {
isProcessing = false;
updateUI();
return submitChatMessage({ message: txt, isStart, force28b, queueWait: true });
}
if (display && normalizeMode(currentMode) === mode) {
display.innerHTML += `<div class="response-block" style="border-left-color:#b26a00;"><b>INFO:</b> Consultation request cancelled. You can retry later.</div>`;
display.scrollTop = display.scrollHeight;
}
} else {
if (display && normalizeMode(currentMode) === mode) {
display.innerHTML += `<div class="response-block" style="border-left-color:#b26a00;"><b>GPU BUSY:</b> ${res.error || 'GPU is currently busy. Please retry in a moment.'}</div>`;
display.scrollTop = display.scrollHeight;
}
if (res.error) {
alert(res.error);
}
}
} else if (res.confirm_28b) {
const ok = confirm(res.error || 'The 28B model on CPU can take an hour or more. Continue?');
if (ok) {
isProcessing = false;
updateUI();
return submitChatMessage({ message: txt, isStart, force28b: true });
}
if (display && normalizeMode(currentMode) === mode) {
display.innerHTML += `<div class="response-block" style="border-left-color:var(--red);"><b>INFO:</b> ${res.error || 'Cancelled running 28B model.'}</div>`;
}
} else if (res.error) {
if (display && normalizeMode(currentMode) === mode) {
display.innerHTML += `<div class="response-block" style="border-left-color:var(--red);"><b>ERROR:</b> ${res.error}</div>`;
}
} else {
modeSession.sessionId = res.session_id || modeSession.sessionId;
modeSession.transcript = Array.isArray(res.transcript) ? res.transcript : modeSession.transcript;
modeSession.sessionMeta = {
...(metaPayload || {}),
...(res.session_meta || {}),
session_id: modeSession.sessionId,
mode,
};
if (res.model && res.model_metrics) {
chatMetrics[res.model] = res.model_metrics;
} else if (res.model && Number.isFinite(Number(res.duration_ms))) {
const durMs = Number(res.duration_ms);
const current = chatMetrics[res.model] || { count: 0, total_ms: 0, avg_ms: 0 };
const nextCount = (current.count || 0) + 1;
const nextTotal = (current.total_ms || 0) + durMs;
chatMetrics[res.model] = {
count: nextCount,
total_ms: nextTotal,
avg_ms: nextTotal / nextCount,
};
}
if (normalizeMode(currentMode) === mode) {
renderTranscript(modeSession.transcript, { scrollTo: 'latest-assistant-start' });
}
updateUI();
if (normalizeMode(currentMode) === mode) {
syncStartPanelWithConsultationState();
}
try { localStorage.setItem(SKIP_LAST_CHAT_KEY, '0'); } catch (err) { /* ignore */ }
if (typeof loadData === 'function') {
// After a response we only need history/settings freshness; roster reuse is faster.
loadData({ skipPatients: true });
}
const chatInput = document.getElementById('chat-input');
if (chatInput) chatInput.value = '';
}
persistChatState();
try {
localStorage.setItem(LAST_PROMPT_KEY, lastPrompt);
localStorage.setItem(LAST_CHAT_MODE_KEY, currentMode);
} catch (err) { /* ignore */ }
} catch (error) {
loadingDiv.remove();
if (display && normalizeMode(currentMode) === mode) {
display.innerHTML += `<div class="response-block" style="border-left-color:var(--red);"><b>ERROR:</b> ${error.message}</div>`;
}
} finally {
isProcessing = false;
if (isStart) {
triageSupplementDialogShownThisStart = false;
}
clearBlockerCountdown();
const blocker = document.getElementById('chat-blocker');
if (blocker) blocker.classList.remove('active');
updateUI();
}
}
async function runChat(promptText = null, force28b = false) {
if (isPublicDemoChatDisabled()) {
showPublicDemoChatDisabledModal();
return;
}
if (!hasRunnableLocalModel()) {
alert(noLocalModelsMessage());
updateUI();
return;
}
if (isSessionActive(currentMode)) {
alert('Expand "Start New Consultation" to begin a new consultation.');
return;
}
if (promptRefreshTimer) {
clearTimeout(promptRefreshTimer);
promptRefreshTimer = null;
}
triageSupplementDialogShownThisStart = false;
const previewData = await refreshPromptPreview();
if (currentMode === 'triage' && previewData?.triage_pathway_supplemented) {
alert(
'Clinical Triage Pathway is not fully defined for the selected choices. '
+ 'Your selection(s) will be supplemented with the general triage prompt from Settings.'
);
triageSupplementDialogShownThisStart = true;
}
const modeSession = getModeSession(currentMode);
modeSession.promptBase = capturePromptBaseForSession();
await submitChatMessage({ message: promptText || document.getElementById('msg').value, isStart: true, force28b });
}
async function sendChatMessage() {
if (isPublicDemoChatDisabled()) {
showPublicDemoChatDisabledModal();
return;
}
if (!hasRunnableLocalModel()) {
alert(noLocalModelsMessage());
updateUI();
return;
}
if (!isSessionActive(currentMode)) {
alert('Start a new consultation first.');
return;
}
const input = document.getElementById('chat-input');
const message = input ? input.value : '';
await submitChatMessage({ message, isStart: false });
}
// Handle Enter key for submission
document.addEventListener('DOMContentLoaded', () => {
// Apply mode/model UI state immediately so selectors are ready before
// deferred startup work completes.
updateUI();
refreshModelAvailability({ silent: true }).catch(() => {});
syncModelSelects();
setupPromptInjectionPanel();
loadTriageDecisionTree()
.then(() => {
applyChatState(currentMode);
persistChatState(currentMode);
schedulePromptRefresh(0);
})
.catch(() => {});
const msgTextarea = document.getElementById('msg');
if (msgTextarea) {
msgTextarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
runChat();
}
});
}
const chatInput = document.getElementById('chat-input');
if (chatInput) {
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
});
}
const modelSelect = document.getElementById('model-select');
if (modelSelect && !modelSelect.dataset.bound) {
modelSelect.dataset.bound = 'true';
modelSelect.addEventListener('change', () => {
syncModelSelects('model-select');
persistChatState(currentMode);
});
}
const chatModelSelect = document.getElementById('chat-model-select');
if (chatModelSelect && !chatModelSelect.dataset.bound) {
chatModelSelect.dataset.bound = 'true';
chatModelSelect.addEventListener('change', () => {
syncModelSelects('chat-model-select');
persistChatState(currentMode);
});
}
const patientSelect = document.getElementById('p-select');
if (patientSelect && !patientSelect.dataset.chatModeBound) {
patientSelect.dataset.chatModeBound = 'true';
patientSelect.addEventListener('change', () => {
persistChatState(currentMode);
schedulePromptRefresh(0);
});
}
const savedPrompt = localStorage.getItem(LAST_PROMPT_KEY);
if (savedPrompt) lastPrompt = savedPrompt;
// Debug current patient select state
const pSelect = document.getElementById('p-select');
if (!pSelect) console.warn('Patient selector not found on startup');
});
/**
* Restore a previous consultation session from history.
*
* Restoration Process:
* 1. Loads full history from server
* 2. Finds target entry by ID
* 3. Switches to correct mode
* 4. Restores patient selection
* 5. Loads full transcript into the chat UI
*
* Use Cases:
* - Continue interrupted consultations
* - Follow up after implementing advice
* - Review and expand on previous diagnosis
* - Share context with relief crew
*
* @param {string} historyId - Unique ID of history entry to restore
*/
async function restoreChatSession(historyId) {
if (!historyId) return;
if (isProcessing) return;
try {
const entry = await loadHistoryEntryById(historyId);
if (!entry) {
alert('Unable to restore: history entry not found.');
return;
}
const restored = restoreHistoryEntrySession(entry, {
focusInput: true,
forceLoggingOn: true,
notifyRestored: true,
navigateToChat: true,
allowTakeover: true,
});
if (!restored) return;
} catch (err) {
alert(`Unable to restore session: ${err.message}`);
}
}
/**
* historyEntryTimestamp: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function historyEntryTimestamp(entry) {
if (!entry || typeof entry !== 'object') return Number.NEGATIVE_INFINITY;
const raw = entry.updated_at || entry.date || '';
if (!raw) return Number.NEGATIVE_INFINITY;
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
const ts = Date.parse(normalized);
return Number.isNaN(ts) ? Number.NEGATIVE_INFINITY : ts;
}
/**
* findMostRecentHistoryEntry: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function findMostRecentHistoryEntry(entries) {
if (!Array.isArray(entries) || !entries.length) return null;
const sorted = entries.slice().sort((a, b) => historyEntryTimestamp(b) - historyEntryTimestamp(a));
return sorted[0] || null;
}
async function loadHistoryEntryById(historyId) {
if (!historyId) return null;
const res = await fetch(`/api/history/${encodeURIComponent(historyId)}`, { credentials: 'same-origin' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`History load failed (${res.status})`);
const entry = await res.json();
return (entry && typeof entry === 'object') ? entry : null;
}
async function restoreChatAsReturned(historyId) {
if (!historyId) return;
if (isProcessing) return;
try {
const entry = await loadHistoryEntryById(historyId);
if (!entry) {
alert('Unable to restore: history entry not found.');
return;
}
const restored = restoreHistoryEntrySession(entry, {
focusInput: false,
forceLoggingOn: true,
notifyRestored: false,
navigateToChat: true,
allowTakeover: true,
confirmTakeover: false,
scrollTo: 'latest-assistant-start',
forceStartPanelExpanded: true,
});
if (!restored) return;
} catch (err) {
alert(`Unable to restore session: ${err.message}`);
}
}
async function restoreLatestChatAsReturned() {
if (isProcessing) return;
try {
const res = await fetch('/api/data/history', { credentials: 'same-origin' });
if (!res.ok) throw new Error(`History load failed (${res.status})`);
const data = await res.json();
const latest = findMostRecentHistoryEntry(Array.isArray(data) ? data : []);
if (!latest || !latest.id) {
alert('No consultation log entries found to restore.');
return;
}
await restoreChatAsReturned(latest.id);
} catch (err) {
alert(`Unable to restore latest session: ${err.message}`);
}
}
async function activateChatTabForRestore() {
const chatTabBtn = document.querySelector(`.tab[onclick*="'Chat'"]`);
if (chatTabBtn && typeof window.showTab === 'function') {
try {
const maybePromise = window.showTab(chatTabBtn, 'Chat');
if (maybePromise && typeof maybePromise.then === 'function') {
await maybePromise;
}
return;
} catch (err) {
console.warn('Failed to activate Chat tab during restore', err);
}
}
if (chatTabBtn && typeof chatTabBtn.click === 'function') {
chatTabBtn.click();
return;
}
const chatPanel = document.getElementById('Chat');
if (chatPanel) {
document.querySelectorAll('.content').forEach((c) => { c.style.display = 'none'; });
chatPanel.style.display = 'flex';
}
if (typeof updateUI === 'function') {
updateUI();
}
}
/**
* showRestoredSessionNotice: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function showRestoredSessionNotice(mode, patientName) {
const display = document.getElementById('display');
if (!display) return;
const existing = document.getElementById('chat-restore-notice');
if (existing) existing.remove();
const modeLabel = mode === 'inquiry' ? 'Inquiry' : 'Triage';
const patientPart = patientName ? ` for ${patientName}` : '';
const notice = document.createElement('div');
notice.id = 'chat-restore-notice';
notice.style.cssText = 'margin:0 0 10px 0; padding:8px 10px; border-left:4px solid #2e7d32; background:#eef8ef; color:#13361a; font-size:13px; font-weight:600;';
notice.textContent = `Session restored: ${modeLabel}${patientPart}. Continue below.`;
display.prepend(notice);
}
/**
* setSelectValueByValueOrLabel: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function setSelectValueByValueOrLabel(select, rawValue) {
if (!select) return;
const wanted = String(rawValue || '').trim();
if (!wanted) {
select.value = '';
return;
}
const options = Array.from(select.options || []);
const exact = options.find((o) => String(o.value || '').trim() === wanted);
if (exact) {
select.value = exact.value;
return;
}
const wantedLower = wanted.toLowerCase();
const byLabel = options.find((o) => String(o.textContent || '').trim().toLowerCase() === wantedLower);
select.value = byLabel ? byLabel.value : '';
}
/**
* restoreStartFormFromHistory: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function restoreStartFormFromHistory(entry, messages, meta, mode) {
const msgEl = document.getElementById('msg');
const firstUserMsg = Array.isArray(messages)
? messages.find((m) => (m?.role || m?.type || '').toString().toLowerCase() === 'user')
: null;
const initialQuery = (meta?.initial_query || entry?.query || firstUserMsg?.message || '').toString();
if (msgEl) msgEl.value = initialQuery;
if (mode !== 'triage') {
persistChatState(mode);
return;
}
const pathFromMeta = (meta?.triage_path && typeof meta.triage_path === 'object') ? meta.triage_path : {};
const conditionFromMeta = (meta?.triage_condition && typeof meta.triage_condition === 'object') ? meta.triage_condition : {};
const triageMeta = (firstUserMsg?.triage_meta && typeof firstUserMsg.triage_meta === 'object')
? firstUserMsg.triage_meta
: ((firstUserMsg?.triageMeta && typeof firstUserMsg.triageMeta === 'object') ? firstUserMsg.triageMeta : {});
const path = {
domain: pathFromMeta.domain || triageMeta['Domain'] || '',
problem: pathFromMeta.problem || triageMeta['Problem / Injury Type'] || '',
anatomy: pathFromMeta.anatomy || triageMeta['Anatomy'] || '',
severity: pathFromMeta.severity || triageMeta['Severity / Complication'] || '',
mechanism: pathFromMeta.mechanism || triageMeta['Mechanism / Cause'] || '',
};
const condition = {
consciousness: conditionFromMeta.consciousness || triageMeta['Consciousness'] || '',
breathing: conditionFromMeta.breathing || triageMeta['Breathing'] || '',
circulation: conditionFromMeta.circulation || triageMeta['Circulation'] || '',
overall_stability: conditionFromMeta.overall_stability || triageMeta['Overall Stability'] || '',
};
setSelectValueByValueOrLabel(document.getElementById('triage-consciousness'), condition.consciousness);
setSelectValueByValueOrLabel(document.getElementById('triage-breathing'), condition.breathing);
setSelectValueByValueOrLabel(document.getElementById('triage-circulation'), condition.circulation);
setSelectValueByValueOrLabel(document.getElementById('triage-overall-stability'), condition.overall_stability);
const domainEl = document.getElementById('triage-domain');
const problemEl = document.getElementById('triage-problem');
const anatomyEl = document.getElementById('triage-anatomy');
const severityEl = document.getElementById('triage-severity');
const mechanismEl = document.getElementById('triage-mechanism');
setSelectValueByValueOrLabel(domainEl, path.domain);
if (typeof syncTriageTreeSelections === 'function') syncTriageTreeSelections();
setSelectValueByValueOrLabel(problemEl, path.problem);
if (typeof syncTriageTreeSelections === 'function') syncTriageTreeSelections();
setSelectValueByValueOrLabel(anatomyEl, path.anatomy);
if (typeof syncTriageTreeSelections === 'function') syncTriageTreeSelections();
setSelectValueByValueOrLabel(severityEl, path.severity);
if (typeof syncTriageTreeSelections === 'function') syncTriageTreeSelections();
setSelectValueByValueOrLabel(mechanismEl, path.mechanism);
if (typeof syncTriageTreeSelections === 'function') syncTriageTreeSelections();
persistChatState(mode);
schedulePromptRefresh(0);
}
/**
* restoreHistoryEntrySession: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function restoreHistoryEntrySession(entry, options = {}) {
if (!entry || typeof entry !== 'object') return false;
if (isProcessing) return false;
const opts = {
focusInput: false,
forceLoggingOn: true,
notifyRestored: false,
allowTakeover: false,
confirmTakeover: true,
scrollTo: 'bottom',
forceStartPanelExpanded: false,
navigateToChat: false,
...options,
};
const parsed = parseHistoryTranscriptEntry(entry);
const messages = parsed.messages || [];
if (!messages.length) return false;
const meta = parsed.meta || {};
const mode = (entry.mode || meta.mode || 'triage') === 'inquiry' ? 'inquiry' : 'triage';
if (isSessionActive(mode)) {
if (!opts.allowTakeover) return false;
if (opts.confirmTakeover) {
const ok = confirm('Restore a previous session? This will end the current session and clear the transcript (it remains in the Consultation Log if logging is on).');
if (!ok) return false;
}
endActiveSession({ clearDisplay: normalizeMode(currentMode) === mode, mode });
if (normalizeMode(currentMode) === mode) {
resetStartForm();
}
}
if (normalizeMode(currentMode) !== mode) {
setMode(mode);
} else {
updateUI();
}
const patientSelect = document.getElementById('p-select');
if (patientSelect) {
let targetVal = entry.patient_id || meta.patient_id || '';
if (targetVal && Array.from(patientSelect.options).some((o) => o.value === targetVal)) {
patientSelect.value = targetVal;
} else if (entry.patient || meta.patient) {
const name = entry.patient || meta.patient;
const matchByName = Array.from(patientSelect.options).find((o) => o.textContent === name);
if (matchByName) patientSelect.value = matchByName.value;
}
try { localStorage.setItem(LAST_PATIENT_KEY, patientSelect.value || ''); } catch (err) { /* ignore */ }
}
const modeSession = getModeSession(mode);
modeSession.promptBase = meta.prompt_base || meta.promptBase || '';
modeSession.sessionId = entry.id || meta.session_id || `session-${Date.now()}`;
modeSession.transcript = messages;
modeSession.sessionMeta = {
session_id: modeSession.sessionId,
mode,
patient_id: entry.patient_id || meta.patient_id || '',
patient: entry.patient || meta.patient || '',
initial_query: entry.query || meta.initial_query || '',
started_at: entry.date || meta.started_at || meta.date || '',
prompt_base: modeSession.promptBase || undefined,
};
restoreStartFormFromHistory(entry, messages, meta, mode);
const promptBox = document.getElementById('prompt-preview');
if (promptBox && modeSession.promptBase) {
promptBox.value = modeSession.promptBase;
promptBox.dataset.autofilled = 'false';
}
const chatModelSelect = document.getElementById('chat-model-select');
if (chatModelSelect && entry.model) {
chatModelSelect.value = entry.model;
syncModelSelects('chat-model-select');
}
if (opts.forceLoggingOn) {
isPrivate = false;
try { localStorage.setItem(LOGGING_MODE_KEY, '0'); } catch (err) { /* ignore */ }
}
renderTranscript(modeSession.transcript, { scrollTo: opts.scrollTo || 'bottom' });
updateUI();
syncStartPanelWithConsultationState();
const applyForcedStartPanelExpansion = () => {
if (
opts.forceStartPanelExpanded
&& normalizeMode(mode) === 'triage'
&& normalizeMode(currentMode) === 'triage'
) {
setStartPanelExpanded(true);
}
};
if (!opts.navigateToChat) {
applyForcedStartPanelExpansion();
}
persistChatState(mode);
try { localStorage.setItem(SKIP_LAST_CHAT_KEY, '0'); } catch (err) { /* ignore */ }
if (opts.navigateToChat) {
const navPromise = activateChatTabForRestore();
if (navPromise && typeof navPromise.finally === 'function') {
navPromise.finally(() => {
applyForcedStartPanelExpansion();
});
} else {
applyForcedStartPanelExpansion();
}
}
if (opts.focusInput) {
const chatInput = document.getElementById('chat-input');
if (chatInput) {
chatInput.focus();
}
}
if (opts.notifyRestored) {
showRestoredSessionNotice(mode, modeSession.sessionMeta?.patient || '');
}
return true;
}
window.restoreChatSession = restoreChatSession;
window.restoreChatAsReturned = restoreChatAsReturned;
window.restoreLatestChatAsReturned = restoreLatestChatAsReturned;
window.restoreHistoryEntrySession = restoreHistoryEntrySession;
window.sendChatMessage = sendChatMessage;
window.updateStartPanelTitle = updateStartPanelTitle;
window.handleStartPanelToggle = handleStartPanelToggle;
window.syncStartPanelWithConsultationState = syncStartPanelWithConsultationState;
window.renderTranscript = renderTranscript;
window.resetConsultationUiForDemo = resetConsultationUiForDemo;
window.refreshModelAvailability = refreshModelAvailability;
/**
* syncPromptPreviewForMode: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function syncPromptPreviewForMode(mode) {
const promptBox = document.getElementById('prompt-preview');
if (!promptBox) return;
const modeSession = getModeSession(mode);
if (modeSession.promptBase) {
promptBox.value = modeSession.promptBase;
promptBox.dataset.autofilled = 'false';
return;
}
// No mode-specific override: reset to auto mode and rebuild prompt from settings.
promptBox.value = '';
promptBox.dataset.autofilled = 'true';
refreshPromptPreview(true);
}
/**
* Switch chat mode (triage ↔ inquiry) with state preservation.
*
* Mode Switch Process:
* 1. Save current mode's state (message + fields)
* 2. Update currentMode variable
* 3. Update all UI elements (colors, visibility, text)
* 4. Update mode selector dropdown
* 5. Restore new mode's previously saved state
* 6. Refresh prompt preview if editor is open
*
* State Preservation:
* Allows users to switch modes without losing unsaved work. Each mode's
* state (message text + triage fields) is independently persisted.
*
* Use Case Example:
* - User enters triage consultation
* - Realizes it's non-emergency
* - Switches to inquiry mode (triage data preserved)
* - Later switches back (triage fields restored)
*
* @param {string} mode - Target mode: 'triage' or 'inquiry'
*/
function setMode(mode) {
const target = mode === 'inquiry' ? 'inquiry' : 'triage';
if (target === currentMode) return;
// Save current mode state before switching
persistChatState(currentMode);
currentMode = target;
const select = document.getElementById('mode-select');
if (select) {
select.value = target;
}
applyChatState(target);
syncPromptPreviewForMode(target);
updateUI();
syncCurrentModeTranscriptView();
syncStartPanelWithConsultationState();
}
/**
* Toggle between triage and inquiry modes.
* Convenience wrapper for setMode() that flips between the two modes.
*/
function toggleMode() {
setMode(currentMode === 'triage' ? 'inquiry' : 'triage');
}
/**
* Normalize whitespace in prompt text.
*
* Cleans excessive blank lines (3+ → 2) and trims leading/trailing whitespace.
* Improves prompt readability without affecting semantic content.
*
* @param {string} text - Raw prompt text
* @returns {string} Cleaned text
*/
function cleanPromptWhitespace(text) {
return (text || '').replace(/\n{3,}/g, '\n\n').trim();
}
/**
* Remove user message from prompt text to show template only.
*
* When displaying prompt preview, we want to show the system instructions
* and context without the user's actual message embedded. This keeps the
* editor clean and focused on the template/customization.
*
* The user message is visible in the main message textarea, so including
* it in the prompt preview would be redundant and confusing.
*
* Removal Strategies:
* 1. Find label line (QUERY: or SITUATION:) and clear text after label
* 2. Search for exact message text and remove it
* 3. Fall back to returning prompt as-is if no match found
*
* @param {string} promptText - Full prompt including message
* @param {string} userMessage - User's message to remove
* @param {string} mode - Current chat mode
* @returns {string} Prompt with user message removed
*/
function stripUserMessageFromPrompt(promptText, userMessage, mode = currentMode) {
if (!promptText) return '';
const msg = (userMessage || '').trim();
const label = mode === 'inquiry' ? 'QUERY:' : 'SITUATION:';
const lines = promptText.split('\n');
const idx = lines.findIndex(line => line.trimStart().startsWith(label));
if (idx !== -1) {
const labelPos = lines[idx].indexOf(label);
const prefix = labelPos >= 0 ? lines[idx].slice(0, labelPos) : '';
lines[idx] = `${prefix}${label} `;
return cleanPromptWhitespace(lines.join('\n'));
}
if (msg && promptText.includes(msg)) {
return cleanPromptWhitespace(promptText.replace(msg, ''));
}
return cleanPromptWhitespace(promptText);
}
/**
* Build final prompt by injecting user message into custom prompt template.
*
* When user has edited the prompt in the preview editor, this function
* combines their custom template with the actual user message before
* sending to the AI model.
*
* Injection Strategies:
* 1. Find label line (QUERY: or SITUATION:) and append message
* 2. If no label found, append with label at end
*
* This allows users to:
* - Customize system instructions
* - Add constraints or guidelines
* - Modify context injection
* - Override default prompts entirely
*
* While still ensuring the user's actual question/situation is included.
*
* @param {string} basePrompt - Custom prompt template (without user message)
* @param {string} userMessage - User's message to inject
* @param {string} mode - Current chat mode
* @returns {string} Complete prompt ready for API submission
*/
function buildOverridePrompt(basePrompt, userMessage, mode = currentMode) {
const base = cleanPromptWhitespace(basePrompt);
const msg = (userMessage || '').trim();
if (!base) return '';
if (!msg) return base;
const label = mode === 'inquiry' ? 'QUERY:' : 'SITUATION:';
const lines = base.split('\n');
const idx = lines.findIndex(line => line.trimStart().startsWith(label));
if (idx !== -1) {
const labelPos = lines[idx].indexOf(label);
const prefix = labelPos >= 0 ? lines[idx].slice(0, labelPos) : '';
lines[idx] = `${prefix}${label} ${msg}`;
return lines.join('\n');
}
return `${base}\n\n${label} ${msg}`;
}
/**
* collectTriageMeta: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function collectTriageMeta() {
const meta = {};
const add = (label, value) => {
if (value) meta[label] = value;
};
add('Consciousness', formatTriageOptionLabel(document.getElementById('triage-consciousness')?.value || ''));
add('Breathing', formatTriageOptionLabel(document.getElementById('triage-breathing')?.value || ''));
add('Circulation', formatTriageOptionLabel(document.getElementById('triage-circulation')?.value || ''));
add('Overall Stability', formatTriageOptionLabel(document.getElementById('triage-overall-stability')?.value || ''));
add('Domain', formatTriageOptionLabel(document.getElementById('triage-domain')?.value || ''));
add('Problem / Injury Type', formatTriageOptionLabel(document.getElementById('triage-problem')?.value || ''));
add('Anatomy', formatTriageOptionLabel(document.getElementById('triage-anatomy')?.value || ''));
add('Mechanism / Cause', formatTriageOptionLabel(document.getElementById('triage-severity')?.value || ''));
add('Severity / Complication', formatTriageOptionLabel(document.getElementById('triage-mechanism')?.value || ''));
return meta;
}
/**
* formatTriageMetaBlock: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function formatTriageMetaBlock(meta) {
if (!meta || typeof meta !== 'object') return '';
const lines = Object.entries(meta)
.filter(([, v]) => v)
.map(([k, v]) => `- ${k}: ${v}`);
if (!lines.length) return '';
return `TRIAGE INTAKE:\n${lines.join('\n')}`;
}
/**
* buildConversationText: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function buildConversationText(messages, nextMessage, nextMeta) {
const lines = [];
(messages || []).forEach((msg) => {
if (!msg || typeof msg !== 'object') return;
const role = (msg.role || msg.type || '').toString().trim().toLowerCase();
const content = msg.message || msg.content || '';
if (!content) return;
const label = role === 'user' ? 'USER' : 'ASSISTANT';
if (role === 'user') {
const metaBlock = formatTriageMetaBlock(msg.triage_meta || msg.triageMeta);
if (metaBlock) lines.push(metaBlock);
}
lines.push(`${label}: ${content}`);
});
if (nextMessage) {
const nextBlock = formatTriageMetaBlock(nextMeta);
if (nextBlock) lines.push(nextBlock);
lines.push(`USER: ${nextMessage}`);
}
return lines.join('\n').trim();
}
/**
* buildSessionOverridePrompt: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function buildSessionOverridePrompt(basePrompt, transcript, nextMessage, nextMeta, mode = currentMode) {
const base = cleanPromptWhitespace(basePrompt);
if (!base) return '';
const convo = buildConversationText(transcript, nextMessage, nextMeta);
if (!convo) return base;
const label = mode === 'inquiry' ? 'QUERY:' : 'SITUATION:';
const lines = base.split('\n');
const idx = lines.findIndex(line => line.trimStart().startsWith(label));
if (idx !== -1) {
const labelPos = lines[idx].indexOf(label);
const prefix = labelPos >= 0 ? lines[idx].slice(0, labelPos) : '';
lines[idx] = `${prefix}${label}\n${convo}`;
return lines.join('\n');
}
return `${base}\n\n${label}\n${convo}`;
}
/**
* parseHistoryTranscriptEntry: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function parseHistoryTranscriptEntry(entry) {
if (!entry) return { messages: [], meta: {} };
const raw = entry.response;
if (raw && typeof raw === 'object' && Array.isArray(raw.messages)) {
return { messages: raw.messages, meta: raw.meta || {} };
}
if (typeof raw === 'string' && raw.trim().startsWith('{')) {
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.messages)) {
return { messages: parsed.messages, meta: parsed.meta || {} };
}
} catch (err) { /* ignore */ }
}
const messages = [];
if (entry.query) {
messages.push({ role: 'user', message: entry.query, ts: entry.date || '' });
}
if (entry.response) {
messages.push({ role: 'assistant', message: entry.response, ts: entry.date || '' });
}
return { messages, meta: {} };
}
/**
* scrollToLatestAssistantResponseStart: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function scrollToLatestAssistantResponseStart(display) {
if (!display) return;
const assistantBubbles = display.querySelectorAll('.chat-message.assistant');
const latest = assistantBubbles.length ? assistantBubbles[assistantBubbles.length - 1] : null;
if (!latest) {
display.scrollTop = display.scrollHeight;
return;
}
const top = Math.max(0, latest.offsetTop - display.offsetTop);
display.scrollTop = top;
}
/**
* renderTranscript: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function renderTranscript(messages, options = {}) {
const display = document.getElementById('display');
if (!display) return;
const shouldAppend = options.append === true;
const scrollMode = options.scrollTo || 'bottom';
if (!shouldAppend) {
display.innerHTML = '';
}
if (!messages || !messages.length) {
if (!shouldAppend) {
display.innerHTML = buildEmptyResponsePlaceholderHtml();
}
return;
}
messages.forEach((msg) => {
if (!msg || typeof msg !== 'object') return;
const role = (msg.role || msg.type || '').toString().trim().toLowerCase();
const isUser = role === 'user';
const bubble = document.createElement('div');
bubble.className = `chat-message ${isUser ? 'user' : 'assistant'}`;
const metaLine = document.createElement('div');
metaLine.className = 'chat-meta';
const metaParts = [];
metaParts.push(isUser ? 'You' : 'MedGemma');
if (isUser && msg.ts) {
const ts = new Date(msg.ts);
if (!Number.isNaN(ts.getTime())) {
metaParts.push(ts.toLocaleString());
}
}
metaLine.textContent = metaParts.join(' • ');
bubble.appendChild(metaLine);
const content = document.createElement('div');
content.className = 'chat-content';
const raw = msg.message || msg.content || '';
if (!isUser) {
content.innerHTML = renderAssistantMarkdownChat(raw || '');
} else {
content.innerHTML = escapeHtml(raw || '').replace(/\n/g, '<br>');
}
bubble.appendChild(content);
const triageMeta = msg.triage_meta || msg.triageMeta;
if (isUser && triageMeta && typeof triageMeta === 'object') {
const metaEntries = Object.entries(triageMeta).filter(([, v]) => v);
if (metaEntries.length) {
const triageDiv = document.createElement('div');
triageDiv.className = 'chat-triage-meta';
triageDiv.innerHTML = `<strong>Triage Intake</strong><br>${metaEntries
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(v)}`)
.join('<br>')}`;
bubble.appendChild(triageDiv);
}
}
display.appendChild(bubble);
});
if (scrollMode === 'latest-assistant-start') {
scrollToLatestAssistantResponseStart(display);
} else {
display.scrollTop = display.scrollHeight;
}
}
/**
* Refresh prompt preview by fetching generated prompt from server.
*
* Auto-Refresh Logic:
* - Only refreshes if user hasn't manually edited (autofilled=true)
* - Can be forced with force=true parameter
* - Skips if prompt editor has custom content (dataset.autofilled='false')
*
* Preview Generation:
* Calls /api/chat/preview endpoint which:
* 1. Loads custom prompts from settings
* 2. Injects patient medical history
* 3. Adds triage metadata (if in triage mode)
* 4. Returns full prompt text
*
* User Message Handling:
* Strips user message from preview to keep editor clean. The message
* is visible in main textarea, so showing it again would be redundant.
*
* Use Cases:
* - User switches patient → preview updates with new medical history
* - User changes triage fields → preview updates with new metadata
* - User switches modes → preview updates with mode-specific template
* - User expands editor → initial preview loaded
*
* @param {boolean} force - Force refresh even if user has edited prompt
*/
async function refreshPromptPreview(force = false) {
const msgVal = document.getElementById('msg')?.value || '';
const patientVal = document.getElementById('p-select')?.value || '';
const promptBox = document.getElementById('prompt-preview');
if (!promptBox) return null;
if (!force && promptBox.dataset.autofilled === 'false') return null;
const fd = new FormData();
fd.append('message', msgVal);
fd.append('patient', patientVal);
fd.append('mode', currentMode);
if (currentMode === 'triage') {
fd.append('triage_consciousness', document.getElementById('triage-consciousness')?.value || '');
fd.append('triage_breathing', document.getElementById('triage-breathing')?.value || '');
fd.append('triage_circulation', document.getElementById('triage-circulation')?.value || '');
fd.append('triage_overall_stability', document.getElementById('triage-overall-stability')?.value || '');
fd.append('triage_domain', document.getElementById('triage-domain')?.value || '');
fd.append('triage_problem', document.getElementById('triage-problem')?.value || '');
fd.append('triage_anatomy', document.getElementById('triage-anatomy')?.value || '');
fd.append('triage_severity', document.getElementById('triage-severity')?.value || '');
fd.append('triage_mechanism', document.getElementById('triage-mechanism')?.value || '');
}
try {
const res = await fetch('/api/chat/preview', { method: 'POST', body: fd, credentials: 'same-origin' });
if (!res.ok) throw new Error(`Preview failed (${res.status})`);
const data = await res.json();
promptBox.value = stripUserMessageFromPrompt(data.prompt || '', msgVal, data.mode || currentMode);
promptBox.dataset.autofilled = 'true';
return data;
} catch (err) {
promptBox.value = `Unable to build prompt: ${err.message}`;
promptBox.dataset.autofilled = 'true';
return null;
}
}
/**
* resetPromptPreviewToDefault: function-level behavior note for maintainers.
* Keep this block synchronized with implementation changes.
*/
function resetPromptPreviewToDefault() {
const promptBox = document.getElementById('prompt-preview');
if (!promptBox) return;
promptBox.value = '';
promptBox.dataset.autofilled = 'true';
try { localStorage.removeItem(PROMPT_PREVIEW_CONTENT_KEY); } catch (err) { /* ignore */ }
refreshPromptPreview(true);
}
/**
* Toggle visibility of prompt preview/editor panel.
*
* Manages:
* - Container visibility (show/hide)
* - Arrow icon rotation (▸ collapsed, ▾ expanded)
* - Refresh button visibility
* - ARIA attributes for accessibility
* - State persistence to localStorage
*
* When Opened:
* - Triggers auto-refresh to show current prompt
* - Focuses prompt textarea for immediate editing
* - Shows refresh button
* - Persists expanded state
*
* When Closed:
* - Hides container
* - Rotates arrow to collapsed state
* - Hides refresh button
* - Persists collapsed state
*
* Advanced Feature:
* Prompt editor is hidden by default for beginner/standard users.
* Advanced/developer users can expand to see and customize prompts.
*
* @param {HTMLElement} btn - Header button element (or auto-finds)
* @param {boolean} forceOpen - Force specific state (null=toggle, true=open, false=close)
*/
function togglePromptPreviewArrow(btn, forceOpen = null) {
const header = btn || document.getElementById('prompt-preview-header');
const container = document.getElementById('prompt-preview-container') || header?.nextElementSibling;
const refreshRow = document.getElementById('prompt-refresh-row') || container?.querySelector('#prompt-refresh-row');
const icon = header?.querySelector('.detail-icon');
if (!container || !header) return;
const shouldOpen = forceOpen === null ? container.style.display !== 'block' : !!forceOpen;
container.style.display = shouldOpen ? 'block' : 'none';
if (icon) icon.textContent = shouldOpen ? '▾' : '▸';
header.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
if (refreshRow) refreshRow.style.display = shouldOpen ? 'flex' : 'none';
try { localStorage.setItem(PROMPT_PREVIEW_STATE_KEY, shouldOpen ? 'true' : 'false'); } catch (err) { /* ignore */ }
if (shouldOpen) {
refreshPromptPreview();
const promptBox = document.getElementById('prompt-preview') || container.querySelector('#prompt-preview');
if (promptBox) promptBox.focus();
}
}
// Expose inline handlers
window.togglePromptPreviewArrow = togglePromptPreviewArrow;
window.refreshPromptPreview = refreshPromptPreview;
//
// MAINTENANCE NOTE
// Historical auto-generated note blocks were removed because they were repetitive and
// obscured real logic changes during review. Keep focused comments close to behavior-
// critical code paths (UI state hydration, async fetch lifecycle, and mode-gated
// controls) so maintenance remains actionable.