pediatric-ai-scribe-v3/public/js/app.js
Daniel db83255c58 feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.

Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
  private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
  saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
  audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
  downgrade a real session; if the setting cannot be read, authentication is
  required.
- Actions needing an account are hidden rather than offered and refused.

Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:12:36 +02:00

1174 lines
50 KiB
JavaScript

// ============================================================
// APP.JS — Core utilities, tabs, model selector, helpers
// ============================================================
// ── Client-side error logging ──────────────────────────────
function sendError(data) {
try {
navigator.sendBeacon('/api/logs/client-error', new Blob(
[JSON.stringify(data)], { type: 'application/json' }
));
} catch(e) {}
}
window.onerror = function(msg, src, line, col, err) {
// Ignore errors from browser extensions
if (src && src.indexOf('moz-extension') !== -1) return;
if (src && src.indexOf('chrome-extension') !== -1) return;
sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack });
};
window.addEventListener('unhandledrejection', function(e) {
var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack });
});
document.addEventListener('DOMContentLoaded', function() {
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
function getComponentVersion() {
try {
var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]');
var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : '';
return version || 'dev';
} catch(e) { return 'dev'; }
}
var COMPONENT_VERSION = getComponentVersion();
window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION;
var _componentCache = {};
var _componentLoading = {};
var _tabActivation = 0;
function loadComponent(tabEl) {
var component = tabEl.getAttribute('data-component');
if (!component || tabEl.dataset.loaded) return Promise.resolve();
if (_componentLoading[component]) return _componentLoading[component];
_componentLoading[component] = (_componentCache[component] ? Promise.resolve(_componentCache[component]) :
fetch('/components/' + component + '.html?v=' + COMPONENT_VERSION)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); }))
.then(function(html) {
var template = document.createElement('template');
template.innerHTML = html;
var styles = Array.from(template.content.querySelectorAll('link[rel="stylesheet"]')).map(function(link) {
var url = new URL(link.getAttribute('href'), window.location.href);
if (url.origin === window.location.origin) url.searchParams.set('v', COMPONENT_VERSION);
link.href = url.href;
return new Promise(function(resolve, reject) {
link.addEventListener('load', resolve, { once: true });
link.addEventListener('error', function() { reject(new Error('Component stylesheet failed to load')); }, { once: true });
});
});
// CSS must load in its original cascade position, but controls must not work before initialization.
if (styles.length) {
Array.from(template.content.children).forEach(function(child) {
if (!child.hasAttribute('inert')) {
child.setAttribute('inert', '');
child.setAttribute('data-component-pending', '');
}
});
var loading = document.createElement('p');
loading.setAttribute('role', 'status');
loading.setAttribute('data-component-status', '');
loading.textContent = 'Loading…';
tabEl.setAttribute('aria-busy', 'true');
tabEl.replaceChildren(loading, template.content);
} else tabEl.replaceChildren(template.content);
return Promise.all(styles).then(function() { _componentCache[component] = html; });
})
.then(function() {
tabEl.dataset.loaded = '1';
// Re-attach per-tab model selectors
tabEl.querySelectorAll('.tab-model-select').forEach(function(sel) {
if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel);
});
delete _componentLoading[component];
})
.catch(function(err) {
console.warn('[Component] Failed to load ' + component + ':', err);
tabEl.innerHTML = '<div role="alert" style="padding:40px;text-align:center;color:var(--g400);">Failed to load. Please refresh or select this tab again.</div>';
tabEl.removeAttribute('aria-busy');
delete _componentLoading[component];
});
return _componentLoading[component];
}
// Preload the first tab immediately
var firstTab = document.querySelector('.tab-content[data-component]');
if (firstTab) loadComponent(firstTab);
// --- TAB NAVIGATION ---
function activateTab(tabName) {
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
var tabEl = document.getElementById(tabName + '-tab');
// The assistant is reached from the mode switch, not a sidebar button, so a
// tab can legitimately have a section without one. Requiring a button here
// silently sent every /assistant visit to the fallback tab instead.
if (btn ? btn.classList.contains('hidden') : !tabEl) return false;
var activation = ++_tabActivation;
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
if (btn) btn.classList.add('active');
if (tabEl) {
tabEl.classList.add('active');
// Lazy-load component HTML, then fire tabChanged after DOM is ready
loadComponent(tabEl).then(function() {
if (activation !== _tabActivation || (tabEl.hasAttribute('data-component') && !tabEl.dataset.loaded)) return;
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
// Event listeners bind synchronously; only now allow native form interaction.
tabEl.querySelectorAll('[data-component-pending]').forEach(function(child) {
child.removeAttribute('inert');
child.removeAttribute('data-component-pending');
});
var loading = tabEl.querySelector('[data-component-status]');
if (loading) loading.remove();
tabEl.removeAttribute('aria-busy');
});
} else {
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
}
// The assistant's location is the URL (/assistant), not ped_last_tab —
// storing it there made "/" reopen the assistant and defeated its own page.
if (tabName !== 'assistant') {
try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {}
}
syncTabLocation(tabName);
// Close sidebar on mobile after tab click
var sidebar = document.getElementById('sidebar');
if (sidebar && window.innerWidth <= 768) {
sidebar.classList.remove('open');
}
return true;
}
// The assistant is served from its own path; every other tab lives at "/".
// replaceState keeps the URL honest without stacking a history entry per tab.
function syncTabLocation(tabName) {
if (typeof history === 'undefined' || typeof history.replaceState !== 'function') return;
var target = tabName === 'assistant' ? '/assistant' : '/';
if (window.location.pathname === target) return;
try { history.replaceState({ tab: tabName }, '', target + window.location.search + window.location.hash); } catch (e) {}
}
// Browser Back out of /assistant must actually leave the assistant.
window.addEventListener('popstate', function() {
var wanted = window.location.pathname === '/assistant' ? 'assistant' : null;
if (wanted) { activateTab(wanted); return; }
var active = document.querySelector('.tab-btn.active');
if (!active || active.getAttribute('data-tab') !== 'assistant') return;
var fallback = null;
try { fallback = localStorage.getItem('ped_last_tab'); } catch (e) {}
activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter');
});
// Assistant / Workspace switch. The app IS workspace mode, so switching is
// navigation between two pages rather than an in-page state machine — which
// is also what keeps the two looking identical: the only difference is which
// pill is highlighted. A reload is fine; each is its own document.
document.addEventListener('click', function(event) {
var pill = event.target.closest && event.target.closest('[data-assistant-mode]');
if (!pill) return;
var wantsAssistant = pill.getAttribute('data-assistant-mode') === 'assistant';
var onAssistant = window.location.pathname === '/assistant';
// A preview visitor may try the assistant; the workspace is the product, so
// reaching for it is where we ask them to sign in.
if (document.body.classList.contains('assistant-preview') && !wantsAssistant) {
var screen = document.getElementById('auth-screen');
if (screen) screen.style.display = 'flex';
return;
}
if (!onAssistant) {
// In the app you ARE the workspace, so only the Assistant pill navigates.
if (wantsAssistant) window.location.href = '/assistant';
return;
}
// Inside the assistant, Workspace opens the launcher in place rather than
// reloading into whichever tab happened to be last.
if (typeof window.assistantShowWorkspaceLauncher === 'function') {
window.assistantShowWorkspaceLauncher(!wantsAssistant);
}
});
// Expose activateTab globally so auth.js can call it after login
window.activateTab = activateTab;
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
activateTab(btn.getAttribute('data-tab'));
var subtab = btn.getAttribute('data-subtab');
if (subtab && typeof window.wvSwitchSubtab === 'function') {
setTimeout(function() { window.wvSwitchSubtab(subtab); }, 0);
}
});
});
// --- SIDEBAR TOGGLE ---
var sidebar = document.getElementById('sidebar');
var sidebarOverlay = document.getElementById('sidebar-overlay');
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-menu-toggle')) {
if (sidebar) sidebar.classList.toggle('open');
}
if (e.target.closest('#btn-sidebar-close') || e.target.closest('#sidebar-overlay')) {
if (sidebar) sidebar.classList.remove('open');
}
});
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', function() {
if (sidebar) sidebar.classList.remove('open');
});
}
// --- DESKTOP SIDEBAR COLLAPSE ---
// One menu toggle, shared by the app sidebar and the assistant rail. It used
// to be a pin button plus a separate floating expand button here, and a third
// control inside the assistant — three affordances for one idea.
function syncMenuToggles(hidden) {
var label = hidden ? 'Show menu' : 'Hide menu';
document.querySelectorAll('[data-menu-toggle]').forEach(function(button) {
button.setAttribute('aria-expanded', hidden ? 'false' : 'true');
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
button.classList.toggle('is-collapsed', hidden);
});
}
var menuHidden = false;
try { menuHidden = localStorage.getItem('ped_sidebar_collapsed') === '1'; } catch (e) {}
document.body.classList.toggle('menu-hidden', menuHidden);
syncMenuToggles(menuHidden);
document.addEventListener('click', function(event) {
if (!(event.target.closest && event.target.closest('[data-menu-toggle]'))) return;
var nowHidden = document.body.classList.toggle('menu-hidden');
syncMenuToggles(nowHidden);
try { localStorage.setItem('ped_sidebar_collapsed', nowHidden ? '1' : '0'); } catch (e) {}
});
// Set footer year
var yearEl = document.getElementById('app-year');
if (yearEl) yearEl.textContent = new Date().getFullYear();
// Load settings data when settings tab is activated
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'settings') {
if (typeof load2FAStatus === 'function') load2FAStatus();
if (typeof loadSessions === 'function') loadSessions();
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
if (typeof loadMemories === 'function') loadMemories();
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
if (typeof renderAudioBackups === 'function') renderAudioBackups();
if (typeof loadDocuments === 'function') loadDocuments();
}
if (e.detail && e.detail.tab === 'faq') {
// Wire FAQ accordion after component loads
setTimeout(function() {
document.querySelectorAll('.faq-question').forEach(function(btn) {
if (btn._faqWired) return;
btn._faqWired = true;
btn.addEventListener('click', function() {
var item = btn.parentElement;
var isOpen = item.classList.contains('open');
var section = item.closest('.faq-section');
if (section) section.querySelectorAll('.faq-item.open').forEach(function(i) { i.classList.remove('open'); });
if (!isOpen) item.classList.add('open');
});
});
}, 100);
}
});
// --- MODEL SELECTORS ---
window._currentModels = [];
window._currentProvider = 'openrouter';
window._defaultModelId = '';
fetch('/api/models')
.then(function(r) { return r.json(); })
.then(function(data) {
window._currentModels = data.models || [];
window._currentProvider = data.provider || 'openrouter';
window._buildModelOptions = function buildModelOptions(selectEl) {
selectEl.innerHTML = '';
window._currentModels.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
selectEl.appendChild(opt);
});
if (window._defaultModelId) selectEl.value = window._defaultModelId;
}
// Determine default model (admin override or first model)
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
window._defaultModelId = defaultModelId;
// Populate all per-tab model selectors already in DOM
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
window._buildModelOptions(sel);
});
})
.catch(function(err) { console.warn('Models load failed:', err); });
console.log('✅ App.js DOM ready');
}); // end DOMContentLoaded
// ============================================================
// GLOBAL FUNCTIONS (must be outside DOMContentLoaded)
// ============================================================
// ── Set formatted text on output/contenteditable elements ──
// Converts \n to <br> so line breaks survive in contenteditable divs
// and are preserved when copying with innerText
function setOutputText(el, text) {
if (!el) return;
if (typeof el === 'string') el = document.getElementById(el);
if (!el) return;
// Escape HTML, then convert newlines to <br>
var safe = String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
el.innerHTML = safe.replace(/\n/g, '<br>');
}
// ── Data-action event delegation (replaces inline onclick handlers) ──
// Handles data-action="copy|speak|nc-export" on any element,
// allowing removal of 'unsafe-inline' from the Content Security Policy.
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
var targetId = btn.getAttribute('data-target');
if (!action) return;
if (action === 'copy' && targetId) {
if (typeof copyText === 'function') copyText(targetId);
// Log PHI copy event (fire-and-forget, best-effort)
try {
if (window.AccountBoundary.active()) {
fetch('/api/logs/client-event', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ action: 'copy_to_clipboard', target: targetId })
}).catch(function() {});
}
} catch(e) {}
return;
}
if (action === 'speak' && targetId) {
if (typeof speakText === 'function') speakText(targetId);
return;
}
if (action === 'nc-export' && targetId) {
var label = btn.getAttribute('data-label') || 'export';
if (typeof exportToNextcloud === 'function') exportToNextcloud(targetId, label);
return;
}
});
// Attributes also hide controls in subsequently lazy-loaded components.
window.userFeatures = {};
function loadUserFeatures() {
return fetch('/api/user/features', { headers: getAuthHeaders() })
.then(function(r) { if (!r.ok) throw new Error('Feature policy unavailable'); return r.json(); })
.then(function(data) { applyUserFeatures(data.features || {}); })
.catch(function() { applyUserFeatures({}); });
}
function applyUserFeatures(features) {
window.userFeatures = features;
['read_aloud', 'nextcloud', 'memories'].forEach(function(name) {
document.documentElement.setAttribute('data-feature-' + name, features[name] === true ? 'true' : 'false');
});
if (!features.read_aloud) stopReading();
}
document.addEventListener('tabChanged', loadUserFeatures);
// ── Announcement banner ────────────────────────────────────
function loadAnnouncement() {
loadUserFeatures();
fetch('/api/admin/config/announcement', { headers: getAuthHeaders() })
.then(function(r) { if (!r.ok) throw new Error('not ok'); return r.json(); })
.then(function(data) {
if (!data.success) return;
var banner = document.getElementById('announcement-banner');
var text = document.getElementById('announcement-text');
var icon = document.getElementById('announcement-icon');
if (!banner || !text) return;
if (data.enabled && data.text && data.text.trim()) {
text.textContent = data.text;
// type → icon + class
var icons = { info: 'fa-info-circle', warning: 'fa-triangle-exclamation', error: 'fa-circle-xmark', success: 'fa-circle-check' };
var type = data.type || 'info';
if (icon) { icon.className = 'fas ' + (icons[type] || icons.info); }
banner.className = 'announcement-banner ann-' + type;
} else {
banner.className = 'announcement-banner hidden';
}
})
.catch(function() {}); // silently ignore if endpoint not yet available
}
// Close button — dismiss for this page view only (reappears on refresh/re-login)
var closeBtn = document.getElementById('announcement-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
var banner = document.getElementById('announcement-banner');
if (banner) banner.classList.add('hidden');
});
}
function getSelectedModel() {
// Prefer the active tab's own model selector if present
var activeTab = document.querySelector('.tab-content.active');
if (activeTab) {
var tabSel = activeTab.querySelector('.tab-model-select');
if (tabSel && tabSel.value) return tabSel.value;
}
return undefined;
}
function showLoading(text) {
var overlay = document.getElementById('loading-overlay');
var textEl = document.getElementById('loading-text');
if (textEl) textEl.textContent = text || 'Processing...';
if (overlay) { overlay.style.display = 'flex'; overlay.className = overlay.className.replace('hidden', '').trim(); }
}
function hideLoading() {
var overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
}
// Non-blocking busy bar — user can keep working while AI processes
function showBusy(text) {
var bar = document.getElementById('busy-bar');
var textEl = document.getElementById('busy-text');
if (textEl) textEl.textContent = text || 'Processing...';
if (bar) bar.classList.add('active');
}
function hideBusy() {
var bar = document.getElementById('busy-bar');
if (bar) bar.classList.remove('active');
}
// Reusable confirmation modal — replaces browser confirm() and prompt()
// Usage: showConfirm('Are you sure?', function() { doSomething(); });
// With input: showConfirm('Enter email:', function(value) { send(value); }, { input: true, placeholder: 'email@example.com', inputType: 'email' });
// With danger styling: showConfirm('Delete user?', function() { deleteUser(); }, { danger: true, confirmText: 'Delete' });
window.showConfirm = function(message, onConfirm, opts) {
opts = opts || {};
var modal = document.getElementById('confirm-modal');
var text = document.getElementById('confirm-modal-text');
var okBtn = document.getElementById('confirm-modal-ok');
var cancelBtn = document.getElementById('confirm-modal-cancel');
var inputWrap = document.getElementById('confirm-modal-input-wrap');
var inputEl = document.getElementById('confirm-modal-input');
if (!modal || !text || !okBtn) return;
text.textContent = message;
okBtn.textContent = opts.confirmText || 'Confirm';
if (opts.danger) { okBtn.style.background = 'var(--red)'; okBtn.style.borderColor = 'var(--red)'; }
else { okBtn.style.background = ''; okBtn.style.borderColor = ''; }
if (opts.input) {
inputWrap.style.display = 'block';
inputEl.type = opts.inputType || 'text';
inputEl.placeholder = opts.placeholder || '';
inputEl.value = opts.defaultValue || '';
setTimeout(function() { inputEl.focus(); }, 50);
} else {
inputWrap.style.display = 'none';
}
modal.classList.remove('hidden');
function cleanup() {
modal.classList.add('hidden');
okBtn.onclick = null;
cancelBtn.onclick = null;
inputEl.value = '';
}
cancelBtn.onclick = function() { cleanup(); };
okBtn.onclick = function() {
if (opts.input) {
var val = inputEl.value;
if (opts.required && !val.trim()) { showToast(opts.requiredMsg || 'This field is required', 'error'); return; }
cleanup();
if (onConfirm) onConfirm(val);
} else {
cleanup();
if (onConfirm) onConfirm();
}
};
// Allow Enter key to confirm
inputEl.onkeydown = function(e) { if (e.key === 'Enter') okBtn.click(); };
};
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container) { console.log('Toast:', type, message); return; }
var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'success');
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
var iconEl = document.createElement('i');
iconEl.className = 'fas fa-' + icon;
toast.appendChild(iconEl);
toast.appendChild(document.createTextNode(' ' + String(message == null ? '' : message)));
container.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3500);
}
function copyText(elementId) {
var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText || el.textContent || '';
if (!text.trim()) { showToast('Nothing to copy', 'error'); return; }
// Try modern clipboard API (requires HTTPS or localhost)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showToast('Copied!', 'success');
}).catch(function(err) {
// Fallback: select + execCommand
try {
var range = document.createRange();
range.selectNodeContents(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
var ok = document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
} catch (e) {
showToast('Copy not supported in this browser', 'error');
}
});
} else {
// No clipboard API — use execCommand directly
try {
var range = document.createRange();
range.selectNodeContents(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
var ok = document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
} catch (e) {
showToast('Copy not supported in this browser', 'error');
}
}
}
// Speak / Stop
var currentlyReadingId = null;
var currentAudio = null;
var currentAudioURL = null;
var playbackGeneration = 0;
window.addEventListener('account-boundary', stopReading);
function speakText(elementId) {
var boundary = window.AccountBoundary;
var ticket = boundary && boundary.capture();
if (!ticket || window.userFeatures.read_aloud !== true) return;
if (currentlyReadingId === elementId) { stopReading(); return; }
stopReading();
var el = document.getElementById(elementId);
if (!el) return;
var text = (el.innerText || el.textContent).trim();
if (!text) { showToast('Nothing to read', 'error'); return; }
var playback = playbackGeneration;
function current() {
return playback === playbackGeneration && boundary.valid(ticket) && window.userFeatures.read_aloud === true;
}
currentlyReadingId = elementId;
var btn = findReadButton(elementId);
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...'; }
fetch('/api/text-to-speech', {
method: 'POST',
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text })
})
.then(function(r) {
if (!current()) throw boundary.error();
if (!r.ok) {
var error = new Error('TTS request failed (' + r.status + ')');
error.policyDenied = [401, 403, 503].includes(r.status);
throw error;
}
var ttsProvider = r.headers.get('X-TTS-Provider') || 'server';
return r.blob().then(function(blob) { return { blob: blob, provider: ttsProvider }; });
})
.then(function(result) {
if (!current()) throw boundary.error();
currentAudioURL = URL.createObjectURL(result.blob);
currentAudio = new Audio(currentAudioURL);
currentAudio.onended = function() { if (current()) stopReading(); };
currentAudio.onerror = function() {
if (!current()) return;
stopReading(); showToast('Audio playback error', 'error');
};
return Promise.resolve(currentAudio.play()).then(function() {
if (!current()) return;
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
showToast('Reading aloud (' + result.provider + ')', 'info');
});
})
.catch(function(err) {
if (!current()) return; // Stale completions must not stop newer playback either.
stopReading();
playback = playbackGeneration;
// Account/abort/policy failures must never speak the captured clinical text.
if (err.name === 'AbortError') return;
if (!err.policyDenied && current() && 'speechSynthesis' in window) {
var utter = new SpeechSynthesisUtterance(text);
utter.rate = 0.9;
utter.onend = function() { if (current()) stopReading(); };
currentlyReadingId = elementId;
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
window.speechSynthesis.speak(utter);
showToast('TTS unavailable — using browser voice', 'info');
} else {
showToast('Read aloud failed: ' + err.message, 'error');
}
});
}
function stopReading() {
playbackGeneration++;
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
if (currentAudio) {
currentAudio.onended = currentAudio.onerror = null;
currentAudio.pause(); currentAudio = null;
}
if (currentAudioURL) { URL.revokeObjectURL(currentAudioURL); currentAudioURL = null; }
if (currentlyReadingId) {
var btn = findReadButton(currentlyReadingId);
if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-high"></i> Read'; }
currentlyReadingId = null;
}
}
function findReadButton(elementId) {
var el = document.getElementById(elementId);
if (!el) return null;
var card = el.closest('.output-card') || el.closest('.card');
if (!card) return null;
var buttons = card.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var btn = buttons[i];
// data-action="speak" buttons (current approach)
if (btn.getAttribute('data-action') === 'speak' && btn.getAttribute('data-target') === elementId) return btn;
// legacy onclick fallback
var oc = btn.getAttribute('onclick') || '';
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return btn;
}
return null;
}
// Timer
function createTimer(el) {
var s = 0, iv = null;
return {
start: function() { s = 0; this.resume(); },
resume: function() { this.update(); var self = this; if (!iv) iv = setInterval(function() { s++; self.update(); }, 1000); },
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
reset: function() { s = 0; this.update(); },
update: function() { el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }
};
}
// Audio Recorder
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
AudioRecorder.prototype.start = function() {
var self = this; self.chunks = [];
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
.then(function(stream) {
self.stream = stream;
// Codec chain: Opus webm, plain webm, then mp4 (iOS Safari), then the browser default.
var options = { audioBitsPerSecond: 32000 };
var mime = '';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) mime = 'audio/webm;codecs=opus';
else if (MediaRecorder.isTypeSupported('audio/webm')) mime = 'audio/webm';
else if (MediaRecorder.isTypeSupported('audio/mp4')) mime = 'audio/mp4';
if (mime) options.mimeType = mime;
self.mediaRecorder = new MediaRecorder(stream, options);
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
self.mediaRecorder.start(1000);
});
};
AudioRecorder.prototype.stop = function() {
var self = this;
return new Promise(function(resolve) {
if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; }
self.mediaRecorder.onstop = function() {
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
resolve(blob);
};
self.mediaRecorder.stop();
});
};
// ── Native mobile helpers (Capacitor / Android bridge) ──
// These only activate when running inside the native app
window.nativeHaptic = function(style) {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
window.Capacitor.Plugins.Haptics.impact({ style: style || 'medium' });
} else if (navigator.vibrate) {
navigator.vibrate(style === 'heavy' ? 100 : 50);
}
} catch(e) {}
};
// Start/stop Android foreground service for background recording
window.nativeStartRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.startForegroundService(); } catch(e) {}
};
window.nativeStopRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
};
// Keep screen awake during recording.
//
// Prefer the NativeRecording bridge (addJavascriptInterface, so it is present
// on the remote origin the launcher navigates to). The Capacitor KeepAwake
// plugin is kept as a fallback but is NOT installed in this project — relying
// on it alone meant this function silently did nothing and the screen slept
// mid-recording, killing the MediaRecorder.
window.nativeKeepAwake = function(on) {
try {
if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') {
window.NativeRecording.keepAwake(!!on);
return;
}
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
else window.Capacitor.Plugins.KeepAwake.allowSleep();
}
} catch(e) {}
};
// Detect if running in native app
window.isNativeApp = function() {
return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
};
// Check if server-side transcription (Whisper/AWS) is available
window._transcribeAvailable = null; // null = not checked yet, true/false after check
function checkTranscribeStatus() {
if (!window.AccountBoundary.active()) return;
fetch('/api/transcribe/status', {
headers: getAuthHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
window._transcribeAvailable = !!data.available;
window._transcribeProvider = data.provider || 'none';
if (!data.available) {
console.log('[Transcribe] No server transcription configured — using browser speech recognition only');
}
})
.catch(function() { window._transcribeAvailable = false; });
}
function transcribeAudio(blob) {
return _serverTranscribe(blob);
}
function _serverTranscribe(blob) {
// If no server transcription is configured, skip upload entirely
if (window._transcribeAvailable === false) {
return Promise.resolve({ success: false, noProvider: true, error: 'No transcription API configured — using live transcript' });
}
var startTime = Date.now();
var formData = new FormData();
formData.append('audio', blob, 'audio.webm');
var headers = getAuthHeaders();
delete headers['Content-Type']; // FormData supplies its own boundary.
return fetch('/api/transcribe', {
method: 'POST',
headers: headers,
body: formData
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success && blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription failed, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
}
return data;
}).catch(function(err) {
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription error, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
}
return { success: false, error: err.message };
});
}
// Store original source material on an output element (call after generation)
function storeSourceContext(outputElementId, sourceText) {
var el = document.getElementById(outputElementId);
if (el && sourceText) el.dataset.sourceContext = sourceText;
}
// ── Billing code suggestions (called after note generation) ──
function suggestBillingCodes(outputElementId, noteText, noteType, patientAge, visitType) {
// Find or create the billing codes container near the output element
var outputEl = document.getElementById(outputElementId);
if (!outputEl || !noteText) return;
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var containerId = outputElementId.replace('-text', '') + '-billing-codes';
var container = document.getElementById(containerId);
if (!container) {
// Create container dynamically if not in HTML
container = document.createElement('div');
container.id = containerId;
container.className = 'billing-codes-card';
// Insert after the output text element
outputEl.parentNode.insertBefore(container, outputEl.nextSibling);
}
container.className = 'billing-codes-card';
container.innerHTML = '<div style="font-size:12px;color:var(--g500);"><i class="fas fa-spinner fa-spin"></i> Analyzing billing codes...</div>';
fetch('/api/suggest-codes', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
noteType: noteType || 'soap',
patientAge: patientAge || '',
visitType: visitType || 'outpatient'
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || (!data.icd10.length && !data.cpt.length)) {
container.classList.add('hidden');
return;
}
var html = '<h4><i class="fas fa-file-invoice-dollar"></i> Suggested Billing Codes</h4>';
if (data.icd10 && data.icd10.length > 0) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">ICD-10 Diagnoses</div><div>';
data.icd10.forEach(function(c) {
var name = c.name ? ' <span class="billing-code-name">' + escHtml(c.name) + '</span>' : '';
html += '<span class="billing-code-chip icd" title="Click to copy" data-code="' + escHtml(c.code) + '">' + escHtml(c.code) + name + '</span>';
});
html += '</div></div>';
}
if (data.cpt && data.cpt.length > 0) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">CPT / E&M</div><div>';
data.cpt.forEach(function(c) {
var desc = c.desc ? ' <span class="billing-code-name">' + escHtml(c.desc) + '</span>' : '';
html += '<span class="billing-code-chip cpt" title="Click to copy" data-code="' + escHtml(c.code) + '">' + escHtml(c.code) + desc + '</span>';
});
html += '</div></div>';
}
if (data.emLevel) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>';
html += '<span class="billing-code-chip em">Level ' + escHtml(data.emLevel.level) + '</span>';
html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + escHtml(data.emLevel.complexity) + ' | ' + escHtml(data.emLevel.diagnosisCount) + ' dx | ' + escHtml(data.emLevel.rosCount) + ' ROS | ' + escHtml(data.emLevel.peCount) + ' PE</span>';
html += '</div>';
}
html += '<p style="font-size:10px;color:var(--g400);margin:8px 0 0;">Suggestions only. Always verify codes against your institution\'s coding guidelines.</p>';
container.innerHTML = html;
// Wire click-to-copy on chips
container.querySelectorAll('.billing-code-chip').forEach(function(chip) {
chip.addEventListener('click', function() {
var code = chip.dataset.code;
if (code && navigator.clipboard) {
navigator.clipboard.writeText(code);
showToast('Copied: ' + code, 'info');
}
});
});
})
.catch(function() {
container.classList.add('hidden');
});
}
function escHtml(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// ── Don't-miss tooltip (called after sick visit / encounter HPI generates) ──
// Same insertion pattern as suggestBillingCodes — a side card next to the note
// output. Hard-capped at 5 items by the server prompt. Silent on empty / failure.
function suggestDontMiss(outputElementId, noteText, noteType, patientAge, chiefComplaint) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl || !noteText) return;
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var containerId = outputElementId.replace('-text', '') + '-dont-miss';
var container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.className = 'card dont-miss-card';
container.style.cssText = 'margin-top:10px;border-left:3px solid #f59e0b;';
outputEl.parentNode.insertBefore(container, outputEl.nextSibling);
}
container.innerHTML = '<div style="padding:10px 16px;font-size:12px;color:var(--g500);"><i class="fas fa-spinner fa-spin"></i> Reviewing note for high-yield items...</div>';
fetch('/api/dont-miss', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
noteType: noteType || '',
patientAge: patientAge || '',
chiefComplaint: chiefComplaint || ''
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || !data.points || data.points.length === 0) {
container.classList.add('hidden');
return;
}
var html = '<div class="card-header"><h3><i class="fas fa-triangle-exclamation" style="color:#f59e0b;"></i> Don\'t Miss</h3>' +
'<span style="font-size:11px;color:var(--g500);">High-yield items. Suggestions only.</span></div>' +
'<div style="padding:8px 16px;">';
data.points.forEach(function(p) {
var why = p.why ? '<div style="font-size:11px;color:var(--g500);margin-top:2px;">' + escHtml(p.why) + '</div>' : '';
html += '<div style="padding:6px 0;border-bottom:1px solid var(--g100);">' +
'<div style="font-size:13px;color:var(--g800);"><i class="fas fa-circle-exclamation" style="color:#f59e0b;font-size:11px;margin-right:6px;"></i>' + escHtml(p.point) + '</div>' +
why + '</div>';
});
html += '</div>';
container.classList.remove('hidden');
container.innerHTML = html;
})
.catch(function() {
container.classList.add('hidden');
});
}
// ── Patient education handout helper ────────────────────────
// Adds a reusable "Handout" action beside generated clinical notes. The actual
// handout is generated only when the physician clicks Generate in the panel.
function attachPatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var actions = card.querySelector('.output-actions');
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
if (actions && !document.getElementById(prefix + '-patient-ed-btn')) {
var btn = document.createElement('button');
btn.id = prefix + '-patient-ed-btn';
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.innerHTML = '<i class="fas fa-person-breastfeeding"></i> Handout';
btn.addEventListener('click', function() {
var panel = ensurePatientEducationPanel(outputElementId, opts);
panel.classList.remove('hidden');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
actions.appendChild(btn);
}
}
function ensurePatientEducationPanel(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var panelId = prefix + '-patient-ed';
var panel = document.getElementById(panelId);
if (panel) return panel;
panel = document.createElement('div');
panel.id = panelId;
panel.className = 'card patient-ed-card hidden';
panel.style.cssText = 'margin-top:10px;border-left:3px solid #0ea5e9;';
panel.innerHTML =
'<div class="card-header output-header">' +
'<h3><i class="fas fa-person-breastfeeding" style="color:#0ea5e9;"></i> Patient Education Handout</h3>' +
'<div class="output-actions">' +
'<button class="btn-sm btn-primary" id="' + prefix + '-patient-ed-generate" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'</div>' +
'<div style="padding:10px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;align-items:end;">' +
'<div class="demo-field"><label>Parent language</label><select id="' + prefix + '-patient-ed-language">' +
'<option>English</option><option>Spanish</option><option>French</option><option>Arabic</option><option>Haitian Creole</option><option>Chinese</option><option>Russian</option><option>Portuguese</option>' +
'</select></div>' +
'<div class="demo-field"><label>Diagnosis/context</label><input type="text" id="' + prefix + '-patient-ed-diagnosis" placeholder="Optional: diagnosis to emphasize"></div>' +
'<div class="demo-field"><label>Medications</label><input type="text" id="' + prefix + '-patient-ed-meds" placeholder="Optional: meds/doses from plan"></div>' +
'</div>' +
'<div id="' + prefix + '-patient-ed-text" class="output-text" contenteditable="true" style="margin:0 16px 12px;min-height:120px;" data-placeholder="Generated parent handout appears here..."></div>' +
'<div style="padding:0 16px 12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + prefix + '-patient-ed-text"><i class="fas fa-copy"></i> Copy</button>' +
'<span style="font-size:11px;color:var(--g500);">Parent-facing draft. Verify before sharing.</span>' +
'</div>';
outputEl.parentNode.insertBefore(panel, outputEl.nextSibling);
var gen = panel.querySelector('#' + prefix + '-patient-ed-generate');
if (gen) gen.addEventListener('click', function() { generatePatientEducation(outputElementId, opts); });
return panel;
}
function generatePatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var handoutEl = document.getElementById(prefix + '-patient-ed-text');
var langEl = document.getElementById(prefix + '-patient-ed-language');
var dxEl = document.getElementById(prefix + '-patient-ed-diagnosis');
var medsEl = document.getElementById(prefix + '-patient-ed-meds');
var noteText = (outputEl.innerText || outputEl.textContent || '').trim();
if (!noteText) { showToast('No note for handout', 'error'); return; }
if (handoutEl) handoutEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating parent handout...';
fetch('/api/patient-education', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
diagnosis: dxEl ? dxEl.value : '',
medications: medsEl ? medsEl.value : '',
patientAge: opts.patientAge || '',
language: langEl ? langEl.value : 'English',
readingLevel: '6th grade plain language',
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
if (handoutEl) handoutEl.textContent = '';
showToast(data.error || 'Handout generation failed', 'error');
return;
}
setOutputText(handoutEl, data.handout || '');
showToast('Patient handout generated', 'success');
})
.catch(function(err) {
if (handoutEl) handoutEl.textContent = '';
showToast(err.message || 'Handout generation failed', 'error');
});
}
function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId);
var input = document.getElementById(inputElementId);
if (!doc || !input) return;
var docText = doc.innerText.trim();
var instructions = input.value.trim();
if (!docText) { showToast('No document', 'error'); return; }
if (!instructions) { showToast('Enter instructions', 'error'); return; }
showBusy('Refining...');
var body = { currentDocument: docText, instructions: instructions, model: getSelectedModel() };
// Include original source material so AI can reference the full input
if (doc.dataset.sourceContext) body.sourceContext = doc.dataset.sourceContext;
fetch('/api/refine', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (data.success) { setOutputText(doc, data.refined); input.value = ''; showToast('Refined!', 'success'); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function shortenDocument(outputElementId) {
var doc = document.getElementById(outputElementId);
if (!doc) return;
var text = doc.innerText.trim();
if (!text) { showToast('No document', 'error'); return; }
showBusy('Shortening...');
fetch('/api/shorten', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ document: text, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (data.success) { setOutputText(doc, data.shortened); showToast('Shortened!', 'success'); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function exportToNextcloud(elementId, docType) {
var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText.trim();
if (!text) { showToast('Nothing to export', 'error'); return; }
fetch('/api/nextcloud/export', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ content: text, filename: docType + '-' + Date.now(), type: docType })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) showToast(data.message, 'success');
else showToast(data.error || 'Export failed', 'error');
})
.catch(function() { showToast('Nextcloud not connected', 'error'); });
}
function createSpeechRecognition() {
if (window.WebSpeechRecognition && !window.WebSpeechRecognition.isEnabled()) return null;
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
var rec = new SR();
rec.continuous = true;
rec.interimResults = true;
rec.lang = 'en-US';
rec.maxAlternatives = 1;
return rec;
}
// Deduplicate speech recognition finals — Chrome can repeat text across restarts
function deduplicateFinal(newText, existingText) {
if (!newText || !existingText) return newText;
var trimmed = newText.trim();
if (!trimmed) return '';
// Check if the new text is already at the end of existing text
if (existingText.trimEnd().endsWith(trimmed)) return '';
// Check for partial overlap (last sentence repeated)
var words = trimmed.split(/\s+/);
if (words.length >= 3) {
var tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
if (tail === trimmed) return '';
// Check if first half of new text overlaps with end of existing
var half = Math.ceil(words.length / 2);
var firstHalf = words.slice(0, half).join(' ');
if (existingText.trimEnd().endsWith(firstHalf)) {
return words.slice(half).join(' ') + ' ';
}
}
return newText;
}
// PWA Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {});
}
console.log('✅ App.js loaded');