pediatric-ai-scribe-v2/public/js/app.js
ifedan-ed 1c2098d1dd v2.0.0: Pediatric AI Scribe
Features:
- Live encounter recording → HPI (outpatient/inpatient)
- Voice dictation → HPI / SOAP note
- Hospital course generator (prose/day-by-day/organ system/psych)
- Chart review / precharting (outpatient/subspecialty/ED)
- SOAP note generator (full/subjective only)
- Developmental milestones (AAP/Nelson) with narrative + 3-sentence summary
- AI refine & shorten for all outputs
- Ask AI what's missing (clarification)
- Authentication (email/password, email verification, 2FA)
- Nextcloud integration with auto date folders
- PWA support (installable on phone)
- 18+ AI models via OpenRouter
- HIPAA compliance guidance
- Docker support
2026-03-21 16:55:50 -04:00

277 lines
10 KiB
JavaScript

// ============================================================
// APP.JS — Core utilities, tabs, model selector, refine, speak
// ============================================================
// --- TAB NAVIGATION ---
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
btn.classList.add('active');
document.getElementById(btn.getAttribute('data-tab') + '-tab').classList.add('active');
});
});
// --- MODEL SELECTOR ---
var modelSelect = document.getElementById('global-model-select');
var costBadge = document.getElementById('model-cost-badge');
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) {
if (data.models && modelSelect) {
modelSelect.innerHTML = '';
data.models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
var prefix = m.tag === 'FREE' ? '🆓' : m.tag === 'PREMIUM' ? '👑' : m.tag === 'REASONING' ? '🧠' : '⚡';
opt.textContent = prefix + ' ' + m.name + ' (' + m.cost + ')';
modelSelect.appendChild(opt);
});
if (costBadge) costBadge.textContent = data.models[0].cost;
}
}).catch(function() {});
function getSelectedModel() {
return modelSelect ? modelSelect.value : 'google/gemini-2.5-flash';
}
if (modelSelect) {
modelSelect.addEventListener('change', function() {
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) {
var m = data.models.find(function(x) { return x.id === modelSelect.value; });
if (m && costBadge) costBadge.textContent = m.cost;
});
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
});
}
// --- LOADING ---
function showLoading(text) {
document.getElementById('loading-text').textContent = text || 'Processing...';
document.getElementById('loading-overlay').classList.remove('hidden');
}
function hideLoading() {
document.getElementById('loading-overlay').classList.add('hidden');
}
// --- TOAST ---
function showToast(message, type) {
var container = document.getElementById('toast-container');
var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'success');
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
toast.innerHTML = '<i class="fas fa-' + icon + '"></i> ' + message;
container.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3500);
}
// --- COPY ---
function copyText(elementId) {
var el = document.getElementById(elementId);
var text = el.innerText || el.textContent;
navigator.clipboard.writeText(text).then(function() {
showToast('Copied!', 'success');
}).catch(function() {
var range = document.createRange();
range.selectNode(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast('Copied!', 'success');
});
}
// --- SPEAK / STOP ---
var currentlyReadingId = null;
var currentAudio = null;
function speakText(elementId) {
if (currentlyReadingId === elementId) { stopReading(); return; }
stopReading();
var el = document.getElementById(elementId);
var text = (el.innerText || el.textContent).trim();
if (!text) { showToast('Nothing to read', 'error'); return; }
var readBtn = findReadButton(elementId);
useBrowserTTS(text, elementId, readBtn);
}
function useBrowserTTS(text, elementId, readBtn) {
if (!('speechSynthesis' in window)) { showToast('TTS not supported', 'error'); return; }
window.speechSynthesis.cancel();
var utter = new SpeechSynthesisUtterance(text);
utter.rate = 0.9;
utter.onend = function() { stopReading(); };
utter.onerror = function() { stopReading(); };
currentlyReadingId = elementId;
setReadButtonActive(readBtn, true);
window.speechSynthesis.speak(utter);
showToast('Reading — click Stop to end', 'info');
}
function stopReading() {
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
if (currentlyReadingId) {
setReadButtonActive(findReadButton(currentlyReadingId), false);
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 oc = buttons[i].getAttribute('onclick') || '';
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return buttons[i];
}
return null;
}
function setReadButtonActive(btn, active) {
if (!btn) return;
if (active) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
else { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-up"></i> Read'; }
}
// --- TIMER ---
function createTimer(el) {
var s = 0, iv = null;
return {
start: function() { s = 0; this.update(); var self = this; iv = setInterval(function() { s++; self.update(); }, 1000); },
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
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, echoCancellation: true, noiseSuppression: true } })
.then(function(stream) {
self.stream = stream;
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
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();
});
};
// --- TRANSCRIBE HELPER ---
function transcribeAudio(blob) {
var formData = new FormData();
formData.append('audio', blob, 'audio.webm');
return fetch('/api/transcribe', { method: 'POST', body: formData })
.then(function(r) { return r.json(); });
}
// --- REFINE HELPER ---
function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId).innerText.trim();
var instructions = document.getElementById(inputElementId).value.trim();
if (!doc) { showToast('No document to refine', 'error'); return; }
if (!instructions) { showToast('Enter instructions for AI', 'error'); return; }
showLoading('Refining...');
fetch('/api/refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ currentDocument: doc, instructions: instructions, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
document.getElementById(outputElementId).textContent = data.refined;
document.getElementById(inputElementId).value = '';
showToast('Refined!', 'success');
} else {
showToast(data.error || 'Refine failed', 'error');
}
})
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); });
}
// --- SHORTEN HELPER ---
function shortenDocument(outputElementId) {
var doc = document.getElementById(outputElementId).innerText.trim();
if (!doc) { showToast('No document to shorten', 'error'); return; }
showLoading('Shortening...');
fetch('/api/shorten', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ document: doc, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
document.getElementById(outputElementId).textContent = data.shortened;
showToast('Shortened!', 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); });
}
// --- EXPORT NEXTCLOUD HELPER ---
function exportToNextcloud(elementId, docType) {
var text = document.getElementById(elementId).innerText.trim();
if (!text) { showToast('Nothing to export', 'error'); return; }
var date = new Date().toISOString().split('T')[0];
var filename = docType + '-' + date + '-' + Date.now();
fetch('/api/nextcloud/export', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ content: text, filename: filename, 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(err) { showToast('Nextcloud not connected. Go to Settings.', 'error'); });
}
// --- WEB SPEECH RECOGNITION HELPER ---
function createSpeechRecognition() {
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';
return rec;
}
console.log('✅ App.js loaded');
// Register Service Worker for PWA
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function() {
console.log('✅ PWA: Service Worker registered');
}).catch(function(err) {
console.log('PWA: SW registration failed', err);
});
}