Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Removed at the owner's request, entirely rather than switched off: the route's allow-list, anonymous identity and flag lookup; the client's entry path, the authFetch exception that let four endpoints out without an account, and the workspace guard; the CSS, the in-page note, the admin flag and its save/load; the test file and the assertions elsewhere that pinned it. Both settings rows are deleted from app_settings. Two things were checked rather than assumed on the way out. Removing the anonymous identity collapsed every `if (!req.user.preview)` branch to its authenticated side, so image tools, audit logging and citation storage now run unconditionally — which is what they did before preview existed. And the route's gate went back to a bare router.use(authMiddleware), which on a /api mount gates every path below it in server.js; it is scoped to /clinical-assistant again, the guard test catches it either way. Verified after deploy: signed out, status, examples and chat all refuse with 401; signed in, chat still answers with 8 sources; extensions, encounters, documents and admin remain shut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
1620 lines
71 KiB
JavaScript
1620 lines
71 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 whichever menu is open on mobile after a tab click
|
|
if (window.innerWidth <= 768) closeMobileMenus();
|
|
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');
|
|
});
|
|
|
|
// A phone has two menus that never show together: the workspace list (the app
|
|
// sidebar) and the assistant's chat history. Every way out closes both.
|
|
function closeMobileMenus() {
|
|
var appMenu = document.getElementById('sidebar');
|
|
if (appMenu) appMenu.classList.remove('open');
|
|
var layout = document.getElementById('assistant-layout');
|
|
if (layout) layout.classList.remove('mobile-chats-open');
|
|
}
|
|
|
|
// 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';
|
|
if (!onAssistant) {
|
|
// In the app you ARE the workspace, so only the Assistant pill moves.
|
|
// This used to set window.location, which reloads the document and kills
|
|
// a running recording. The assistant is a tab in this same page, and
|
|
// activateTab already rewrites the URL to /assistant, so switching keeps
|
|
// the recorder — and everything else — alive.
|
|
if (!wantsAssistant) return;
|
|
if (typeof window.activateTab === 'function') window.activateTab('assistant');
|
|
else 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);
|
|
}
|
|
});
|
|
|
|
// On a phone the switch is inside the open sheet, so the view it just changed
|
|
// is behind that sheet — tapping Workspace looked like it did nothing.
|
|
document.addEventListener('click', function(event) {
|
|
if (!(event.target.closest && event.target.closest('[data-assistant-mode]'))) return;
|
|
if (window.innerWidth <= 768) closeMobileMenus();
|
|
});
|
|
|
|
// Account card. Rendered in both menus from the same markup, so whichever view
|
|
// you are in ends the same way. Settings and Log out used to be unlabelled
|
|
// icon buttons in the header, which the slim bar no longer explains.
|
|
function initials(name, email) {
|
|
var source = String(name || email || '').trim();
|
|
var parts = source.split(/[\s@._-]+/).filter(Boolean);
|
|
return ((parts[0] || '?')[0] + (parts.length > 1 ? parts[1][0] : '')).toUpperCase();
|
|
}
|
|
|
|
function renderAccountCards() {
|
|
var user = window.CURRENT_USER;
|
|
if (!user) return;
|
|
var name = user.name || user.email || '';
|
|
document.querySelectorAll('.account-card').forEach(function(card) {
|
|
var avatar = card.querySelector('[id^="account-avatar"]');
|
|
var nameEl = card.querySelector('[id^="account-name"]');
|
|
var mailEl = card.querySelector('[id^="account-email"]');
|
|
if (avatar) avatar.textContent = initials(user.name, user.email);
|
|
if (nameEl) nameEl.textContent = name;
|
|
if (mailEl) mailEl.textContent = user.email || '';
|
|
// Admin is only reachable by admins, so only they are offered it.
|
|
if (user.role === 'admin' && !card.querySelector('[data-account-tab="admin"]')) {
|
|
var menu = card.querySelector('.account-menu');
|
|
var logout = menu && menu.querySelector('[data-account-logout]');
|
|
if (menu && logout) {
|
|
var admin = document.createElement('button');
|
|
admin.type = 'button';
|
|
admin.className = 'account-menu-item';
|
|
admin.setAttribute('role', 'menuitem');
|
|
admin.setAttribute('data-account-tab', 'admin');
|
|
admin.innerHTML = '<i class="fas fa-user-shield"></i> Admin';
|
|
menu.insertBefore(admin, logout);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
window.renderAccountCards = renderAccountCards;
|
|
document.addEventListener('tabChanged', renderAccountCards);
|
|
renderAccountCards();
|
|
|
|
document.addEventListener('click', function(event) {
|
|
var toggle = event.target.closest && event.target.closest('.account-card-btn');
|
|
var menus = document.querySelectorAll('.account-menu');
|
|
if (toggle) {
|
|
var mine = toggle.parentElement.querySelector('.account-menu');
|
|
var opening = mine && mine.hidden;
|
|
menus.forEach(function(m) { m.hidden = true; });
|
|
document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); });
|
|
if (mine && opening) { mine.hidden = false; toggle.setAttribute('aria-expanded', 'true'); }
|
|
return;
|
|
}
|
|
var item = event.target.closest && event.target.closest('.account-menu-item');
|
|
menus.forEach(function(m) { m.hidden = true; });
|
|
document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); });
|
|
if (!item) return;
|
|
if (item.hasAttribute('data-account-logout')) {
|
|
var logoutBtn = document.getElementById('btn-logout');
|
|
if (logoutBtn) logoutBtn.click();
|
|
return;
|
|
}
|
|
var tab = item.getAttribute('data-account-tab');
|
|
// Those tabs live in the app, so reaching one from the assistant leaves it.
|
|
if (tab && window.location.pathname === '/assistant') {
|
|
try { localStorage.setItem('ped_last_tab', tab); } catch (e) {}
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
if (tab) activateTab(tab);
|
|
});
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key !== 'Escape') return;
|
|
document.querySelectorAll('.account-menu').forEach(function(m) { m.hidden = true; });
|
|
document.querySelectorAll('.account-card-btn').forEach(function(b) { b.setAttribute('aria-expanded', 'false'); });
|
|
});
|
|
|
|
// ── Search ──────────────────────────────────────────────────────────────────
|
|
// One palette; the view decides what it searches. In the workspace that is the
|
|
// app menu, in the assistant it is the saved chats. Both sources are already
|
|
// in memory, so this needs no new endpoint.
|
|
var searchModal = document.getElementById('menu-search');
|
|
var searchInput = document.getElementById('menu-search-input');
|
|
var searchResults = document.getElementById('menu-search-results');
|
|
|
|
// Sub-navigation is only present once its component has loaded, so give it a
|
|
// moment rather than clicking into an empty tab.
|
|
function openSubItem(sub, tabName, attempts) {
|
|
var host = tabName ? document.getElementById(tabName + '-tab') : document;
|
|
var selector = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + '="' + sub + '"]'; }).join(', ');
|
|
var target = host && host.querySelector(selector);
|
|
if (target) { target.click(); return; }
|
|
if (attempts > 0) setTimeout(function() { openSubItem(sub, tabName, attempts - 1); }, 110);
|
|
}
|
|
|
|
// Sub-navigation inside a tab — the calculator pills, the well-visit ages, the
|
|
// exam regions — is what people actually look for ("bili", not "Calculators").
|
|
// Read generically from whatever a loaded component exposes, so this covers
|
|
// every tab that has sub-navigation rather than one hard-coded list.
|
|
// One list, so what is listed, what is searched and what a result opens can
|
|
// never drift apart. data-em is the bedside emergency sections.
|
|
var SUB_NAV_ATTRS = ['data-calc', 'data-subtab', 'data-section', 'data-em'];
|
|
var SUB_NAV_SELECTOR = SUB_NAV_ATTRS.map(function(attr) { return '[' + attr + ']'; }).join(', ') + ', .calc-nav-pill';
|
|
|
|
function subItemsFor(tabEl, tabName, tabTitle) {
|
|
if (!tabEl || !tabEl.dataset.loaded) return []; // not loaded yet; nothing to read
|
|
var seen = {};
|
|
return Array.prototype.map.call(tabEl.querySelectorAll(SUB_NAV_SELECTOR), function(node) {
|
|
var key = SUB_NAV_ATTRS.reduce(function(found, attr) { return found || node.getAttribute(attr); }, null);
|
|
var title = (node.textContent || '').trim();
|
|
if (!key || !title || title.length > 48 || seen[key + title]) return null;
|
|
seen[key + title] = true;
|
|
return { id: tabName, sub: key, title: title, meta: tabTitle, icon: 'fas fa-arrow-turn-up fa-rotate-90' };
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function searchSources() {
|
|
// Search follows the view, not the address: the assistant's chat view
|
|
// searches chats, and Workspace — including the workspace launcher shown
|
|
// inside the assistant, which keeps the /assistant URL — searches the menu.
|
|
var inChats = document.body.classList.contains('assistant-workspace')
|
|
&& !document.body.classList.contains('assistant-mode-workspace');
|
|
if (inChats && typeof window.assistantSearchableChats === 'function') {
|
|
return { kind: 'chats', items: window.assistantSearchableChats() };
|
|
}
|
|
var items = [];
|
|
Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
|
|
var name = tab.getAttribute('data-tab');
|
|
if (!name || tab.classList.contains('hidden')) return;
|
|
var label = tab.querySelector('span');
|
|
var icon = tab.querySelector('i');
|
|
var title = label ? label.textContent : name;
|
|
items.push({ id: name, title: title, icon: icon ? icon.className : 'fas fa-circle' });
|
|
items = items.concat(subItemsFor(document.getElementById(name + '-tab'), name, title));
|
|
});
|
|
return { kind: 'tabs', items: items };
|
|
}
|
|
|
|
// A component only exposes its sub-navigation once loaded, so warm the tabs
|
|
// that have any. Cheap: each is fetched once and cached by the loader.
|
|
function warmSearchableTabs() {
|
|
['calculators', 'wellvisit', 'peguide', 'vaxschedule', 'bedside'].forEach(function(name) {
|
|
var el = document.getElementById(name + '-tab');
|
|
if (el && el.hasAttribute('data-component') && !el.dataset.loaded) loadComponent(el);
|
|
});
|
|
}
|
|
|
|
function renderSearch(term) {
|
|
if (!searchResults) return;
|
|
var source = searchSources();
|
|
var needle = String(term || '').trim().toLowerCase();
|
|
// With nothing typed the palette lists the main destinations only; the
|
|
// sub-items (individual calculators, exam sections, schedule tabs) would
|
|
// bury them, so they appear once there is something to match.
|
|
var matches = source.items.filter(function(item) {
|
|
if (!needle) return !item.sub;
|
|
return String(item.title || '').toLowerCase().indexOf(needle) !== -1;
|
|
}).slice(0, 40);
|
|
searchResults.innerHTML = '';
|
|
if (!matches.length) {
|
|
var empty = document.createElement('p');
|
|
empty.className = 'menu-search-empty';
|
|
empty.textContent = needle
|
|
? 'Nothing matches “' + term + '”.'
|
|
: (source.kind === 'chats' ? 'No saved chats yet.' : 'Nothing to search.');
|
|
searchResults.appendChild(empty);
|
|
return;
|
|
}
|
|
matches.forEach(function(item, index) {
|
|
var row = document.createElement('button');
|
|
row.type = 'button';
|
|
row.className = 'menu-search-item' + (index === 0 ? ' is-active' : '');
|
|
row.setAttribute('role', 'option');
|
|
row.setAttribute('data-search-kind', source.kind);
|
|
row.setAttribute('data-search-id', item.id);
|
|
if (item.sub) row.setAttribute('data-search-sub', item.sub);
|
|
row.innerHTML = '<i class="' + (item.icon || 'fas fa-message') + '"></i><span></span>' +
|
|
(item.meta ? '<em></em>' : '');
|
|
row.querySelector('span').textContent = item.title || '';
|
|
if (item.meta) row.querySelector('em').textContent = item.meta;
|
|
searchResults.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function openSearch() {
|
|
if (!searchModal) return;
|
|
searchModal.hidden = false;
|
|
warmSearchableTabs();
|
|
if (searchInput) {
|
|
searchInput.value = '';
|
|
searchInput.placeholder = searchSources().kind === 'chats' ? 'Search chats...' : 'Search the workspace...';
|
|
searchInput.focus();
|
|
}
|
|
renderSearch('');
|
|
}
|
|
function closeSearch() { if (searchModal) searchModal.hidden = true; }
|
|
|
|
function runSearchItem(row) {
|
|
if (!row) return;
|
|
var kind = row.getAttribute('data-search-kind');
|
|
var id = row.getAttribute('data-search-id');
|
|
closeSearch();
|
|
if (kind === 'chats') {
|
|
if (typeof window.assistantOpenChat === 'function') window.assistantOpenChat(id);
|
|
return;
|
|
}
|
|
// A sub-item opens its tab and then activates the entry inside it.
|
|
var sub = row.getAttribute('data-search-sub');
|
|
if (sub) {
|
|
if (window.location.pathname === '/assistant') {
|
|
try { localStorage.setItem('ped_last_tab', id); localStorage.setItem('ped_pending_sub', sub); } catch (e) {}
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
activateTab(id);
|
|
// The component may still be loading, so retry briefly rather than
|
|
// clicking into an empty tab.
|
|
openSubItem(sub, id, 20);
|
|
return;
|
|
}
|
|
// A workspace result lives in the app, so reaching one from the assistant
|
|
// leaves it — the same rule the account menu follows.
|
|
if (window.location.pathname === '/assistant') {
|
|
try { localStorage.setItem('ped_last_tab', id); } catch (e) {}
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
activateTab(id);
|
|
}
|
|
|
|
document.addEventListener('click', function(event) {
|
|
if (event.target.closest && event.target.closest('[data-menu-search]')) { openSearch(); return; }
|
|
if (event.target.closest && event.target.closest('#menu-search-close')) { closeSearch(); return; }
|
|
var row = event.target.closest && event.target.closest('.menu-search-item');
|
|
if (row) { runSearchItem(row); return; }
|
|
if (searchModal && !searchModal.hidden && event.target === searchModal) closeSearch();
|
|
});
|
|
if (searchInput) searchInput.addEventListener('input', function() { renderSearch(searchInput.value); });
|
|
document.addEventListener('keydown', function(event) {
|
|
var key = String(event.key || '').toLowerCase();
|
|
if ((event.metaKey || event.ctrlKey) && key === 'k') { event.preventDefault(); openSearch(); return; }
|
|
if (!searchModal || searchModal.hidden) return;
|
|
if (event.key === 'Escape') { closeSearch(); return; }
|
|
var rows = Array.prototype.slice.call(searchResults ? searchResults.querySelectorAll('.menu-search-item') : []);
|
|
if (!rows.length) return;
|
|
var at = rows.findIndex(function(r) { return r.classList.contains('is-active'); });
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
var next = event.key === 'ArrowDown' ? Math.min(at + 1, rows.length - 1) : Math.max(at - 1, 0);
|
|
rows.forEach(function(r) { r.classList.remove('is-active'); });
|
|
rows[next].classList.add('is-active');
|
|
rows[next].scrollIntoView({ block: 'nearest' });
|
|
} else if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
runSearchItem(rows[at === -1 ? 0 : at]);
|
|
}
|
|
});
|
|
|
|
// A sub-item chosen from the assistant navigates here first; open it once the
|
|
// component it lives in is ready.
|
|
try {
|
|
var pendingSub = localStorage.getItem('ped_pending_sub');
|
|
if (pendingSub) {
|
|
localStorage.removeItem('ped_pending_sub');
|
|
openSubItem(pendingSub, null, 30);
|
|
}
|
|
} catch (e) {}
|
|
|
|
// 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')) {
|
|
// The menu follows the view, as on desktop: in the assistant it is the
|
|
// chat history (the assistant's own drawer), everywhere else the
|
|
// workspace list. On a phone the assistant used to open the workspace
|
|
// list, so there was no way to reach a saved chat.
|
|
var railLayout = document.getElementById('assistant-layout');
|
|
if (railLayout && window.innerWidth <= 640 && document.body.classList.contains('assistant-workspace')) {
|
|
railLayout.classList.toggle('mobile-chats-open');
|
|
} else 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;
|
|
// On a phone the menu is a sheet, not a rail, so there is nothing to
|
|
// collapse — the same control closes it instead.
|
|
if (window.innerWidth <= 768) {
|
|
closeMobileMenus();
|
|
return;
|
|
}
|
|
var nowHidden = document.body.classList.toggle('menu-hidden');
|
|
syncMenuToggles(nowHidden);
|
|
try { localStorage.setItem('ped_sidebar_collapsed', nowHidden ? '1' : '0'); } catch (e) {}
|
|
});
|
|
|
|
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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 ────────────────────────────────────
|
|
// The banner is admin-authored and shown on every page, so it renders
|
|
// Markdown but only ever emits inline formatting and links — no images, no
|
|
// headings, no block layout that could push the app around. Anything else,
|
|
// and any failure to sanitise, falls back to the literal text.
|
|
function renderAnnouncement(el, raw) {
|
|
var markdown = String(raw == null ? '' : raw);
|
|
if (!window.marked || typeof window.marked.parseInline !== 'function' ||
|
|
!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
|
el.textContent = markdown;
|
|
return;
|
|
}
|
|
try {
|
|
el.innerHTML = window.DOMPurify.sanitize(window.marked.parseInline(markdown, { gfm: true }), {
|
|
ALLOWED_TAGS: ['strong', 'em', 'b', 'i', 'u', 's', 'code', 'a', 'br'],
|
|
ALLOWED_ATTR: ['href', 'target', 'rel'],
|
|
ADD_ATTR: ['target'],
|
|
FORBID_ATTR: ['style', 'onerror', 'onload', 'onclick', 'onmouseover'],
|
|
ALLOW_DATA_ATTR: false
|
|
});
|
|
} catch (e) {
|
|
el.textContent = markdown;
|
|
}
|
|
}
|
|
|
|
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()) {
|
|
renderAnnouncement(text, 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
|
|
// The screen going to sleep suspends a recording, so hold a wake lock for as
|
|
// long as one is running. The browser drops the lock whenever the page is
|
|
// hidden, so it has to be taken again when the page comes back; without that,
|
|
// one glance away ends the lock for the rest of the session. The native app
|
|
// keeps its own lock through nativeKeepAwake().
|
|
var _wakeLock = null;
|
|
var _wakeLockHolders = 0;
|
|
|
|
function _acquireWakeLock() {
|
|
if (!_wakeLockHolders || _wakeLock) return Promise.resolve(null);
|
|
if (!navigator.wakeLock || typeof navigator.wakeLock.request !== 'function') return Promise.resolve(null);
|
|
if (document.visibilityState !== 'visible') return Promise.resolve(null); // the request would be rejected
|
|
return navigator.wakeLock.request('screen').then(function(lock) {
|
|
_wakeLock = lock;
|
|
lock.addEventListener('release', function() { _wakeLock = null; });
|
|
return lock;
|
|
}).catch(function() { return null; }); // denied or unsupported: recording continues regardless
|
|
}
|
|
|
|
function holdWakeLock() { _wakeLockHolders++; return _acquireWakeLock(); }
|
|
|
|
function releaseWakeLock() {
|
|
_wakeLockHolders = Math.max(0, _wakeLockHolders - 1);
|
|
if (_wakeLockHolders > 0 || !_wakeLock) return;
|
|
var lock = _wakeLock;
|
|
_wakeLock = null;
|
|
try { lock.release(); } catch (e) {}
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.visibilityState === 'visible') _acquireWakeLock();
|
|
});
|
|
// Signing out must not leave the screen pinned awake for a recording that is
|
|
// no longer anyone's.
|
|
window.addEventListener('account-boundary', function() {
|
|
_wakeLockHolders = 0;
|
|
if (_wakeLock) { try { _wakeLock.release(); } catch (e) {} _wakeLock = null; }
|
|
});
|
|
|
|
// Every running recorder, so a logout or a navigation can find one and save it
|
|
// rather than discarding minutes of a consultation.
|
|
var _activeRecorders = new Set();
|
|
window.activeRecordingCount = function() { return _activeRecorders.size; };
|
|
|
|
// Stops each running recording and stores it, tagged with the module it came
|
|
// from, so it can be picked up and transcribed later. Resolves once every one
|
|
// is stored (or has failed to store) — never rejects, because the caller is
|
|
// usually on its way out of the app.
|
|
window.rescueActiveRecordings = function() {
|
|
var recorders = Array.from(_activeRecorders);
|
|
if (!recorders.length) return Promise.resolve([]);
|
|
return Promise.all(recorders.map(function(recorder) {
|
|
var module = recorder._module || 'recording';
|
|
return recorder.stop().then(function(blob) {
|
|
if (!blob || !blob.size || typeof saveAudioBackup !== 'function') return null;
|
|
return saveAudioBackup(blob, module);
|
|
}).then(function(id) { return { module: module, id: id }; })
|
|
.catch(function() { return { module: module, id: null }; });
|
|
}));
|
|
};
|
|
|
|
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
|
|
|
|
// The one codec chain in the app: Opus webm, plain webm, then mp4, then whatever
|
|
// the browser picks. Safari supports none of the webm types and throws
|
|
// NotSupportedError when handed one, so anywhere that built a recorder with
|
|
// "opus, else audio/webm" simply died there — which is what pause/resume did in
|
|
// every module until they were routed through here.
|
|
AudioRecorder.encoding = function() {
|
|
var options = { audioBitsPerSecond: 32000 };
|
|
var candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'];
|
|
for (var i = 0; i < candidates.length; i++) {
|
|
if (MediaRecorder.isTypeSupported(candidates[i])) { options.mimeType = candidates[i]; return options; }
|
|
}
|
|
return options;
|
|
};
|
|
|
|
// Resume after a pause. A paused recorder resumes; one the browser stopped
|
|
// outright is rebuilt on the same stream, so the chunks already captured are
|
|
// kept and the recording continues into the same blob.
|
|
AudioRecorder.prototype.resumeCapture = function() {
|
|
var self = this;
|
|
if (!self.mediaRecorder) return false;
|
|
try {
|
|
if (self.mediaRecorder.state === 'paused') { self.mediaRecorder.resume(); return true; }
|
|
if (self.mediaRecorder.state !== 'inactive') return true;
|
|
if (!self.stream || !self.stream.active) return false;
|
|
self.mediaRecorder = new MediaRecorder(self.stream, AudioRecorder.encoding());
|
|
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
|
|
self.mediaRecorder.start(1000);
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('[Rec] could not resume:', e.message);
|
|
return false;
|
|
}
|
|
};
|
|
AudioRecorder.prototype.start = function() {
|
|
var self = this;
|
|
// Idempotent: a second start on a running recorder would replace the
|
|
// MediaRecorder and silently drop everything captured so far.
|
|
if (self.mediaRecorder && self.mediaRecorder.state === 'recording') return Promise.resolve();
|
|
self.chunks = [];
|
|
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
|
|
.then(function(stream) {
|
|
self.stream = stream;
|
|
self.mediaRecorder = new MediaRecorder(stream, AudioRecorder.encoding());
|
|
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
|
|
// A recording can stop being a recording without anyone noticing: the
|
|
// recorder can error, and the microphone can be taken away entirely (another
|
|
// app claims it, a headset is unplugged, permission is revoked). Neither was
|
|
// reported, so the tab kept showing "recording" while capturing nothing.
|
|
// Whatever was captured up to that point is kept — the chunks are already in
|
|
// self.chunks — so stop() still returns the audio so far.
|
|
self.failure = null;
|
|
self.mediaRecorder.onerror = function(event) {
|
|
self.failure = (event && event.error && event.error.message) || 'The recorder stopped unexpectedly';
|
|
self.notifyFailure();
|
|
};
|
|
stream.getAudioTracks().forEach(function(track) {
|
|
track.addEventListener('ended', function() {
|
|
self.failure = 'The microphone became unavailable';
|
|
self.notifyFailure();
|
|
});
|
|
});
|
|
self.mediaRecorder.start(1000);
|
|
_activeRecorders.add(self);
|
|
self.heldWakeLock = true;
|
|
holdWakeLock();
|
|
});
|
|
};
|
|
// Announced once per recorder, so a caller that is listening can stop cleanly
|
|
// and the person is told rather than left recording silence.
|
|
AudioRecorder.prototype.notifyFailure = function() {
|
|
if (this.notified) return;
|
|
this.notified = true;
|
|
var message = this.failure || 'Recording stopped unexpectedly';
|
|
try {
|
|
if (typeof showToast === 'function') showToast(message + '. Stop and check your microphone.', 'error');
|
|
} catch (e) {}
|
|
try {
|
|
// The recorder itself travels with the event. Without it a listener cannot
|
|
// tell its own recording from someone else's, and the encounter tab would
|
|
// stop a live consultation because the assistant's microphone had failed.
|
|
document.dispatchEvent(new CustomEvent('audio-recorder-failed', {
|
|
detail: { message: message, recorder: this }
|
|
}));
|
|
} catch (e) {}
|
|
};
|
|
|
|
AudioRecorder.prototype.stop = function() {
|
|
var self = this;
|
|
_activeRecorders.delete(self);
|
|
if (self.heldWakeLock) { self.heldWakeLock = false; releaseWakeLock(); }
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
|
|
// ── 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');
|