- Live encounter recording → HPI generation - Physician voice dictation → polished HPI - Developmental milestones checklist (AAP/Nelson) - 3-sentence summary generator - OpenRouter integration with model selector - Docker support - Mobile responsive UI
292 lines
8.3 KiB
JavaScript
292 lines
8.3 KiB
JavaScript
// ============================================================
|
|
// APP.JS - Core utilities and tab navigation
|
|
// ============================================================
|
|
|
|
// --- 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');
|
|
var tabId = btn.getAttribute('data-tab') + '-tab';
|
|
document.getElementById(tabId).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// --- MODEL SELECTOR ---
|
|
var modelSelect = document.getElementById('global-model-select');
|
|
var costBadge = document.getElementById('model-cost-badge');
|
|
var MODEL_COSTS = {
|
|
'google/gemini-2.5-flash': '~$0.001/call',
|
|
'google/gemini-2.5-flash:thinking': '~$0.002/call',
|
|
'deepseek/deepseek-chat-v3-0324': '~$0.001/call',
|
|
'deepseek/deepseek-r1:free': 'FREE ✨',
|
|
'qwen/qwen3-30b-a3b:free': 'FREE ✨',
|
|
'meta-llama/llama-3.1-70b-instruct': '~$0.001/call',
|
|
'openai/gpt-4o': '~$0.01/call',
|
|
'anthropic/claude-sonnet-4': '~$0.015/call'
|
|
};
|
|
|
|
function getSelectedModel() {
|
|
return modelSelect ? modelSelect.value : 'google/gemini-2.5-flash';
|
|
}
|
|
|
|
if (modelSelect) {
|
|
modelSelect.addEventListener('change', function() {
|
|
var m = modelSelect.value;
|
|
if (costBadge) {
|
|
costBadge.textContent = MODEL_COSTS[m] || '~$0.001/call';
|
|
}
|
|
showToast('Model: ' + m.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(); }, 3000);
|
|
}
|
|
|
|
// --- COPY ---
|
|
function copyText(elementId) {
|
|
var el = document.getElementById(elementId);
|
|
var text = el.innerText || el.textContent;
|
|
navigator.clipboard.writeText(text).then(function() {
|
|
showToast('Copied to clipboard!', 'success');
|
|
}).catch(function() {
|
|
// Fallback
|
|
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 READING
|
|
// ============================================================
|
|
var currentlyReadingId = null;
|
|
var currentAudio = null;
|
|
|
|
function speakText(elementId) {
|
|
// If already reading THIS element, stop it
|
|
if (currentlyReadingId === elementId) {
|
|
stopReading();
|
|
return;
|
|
}
|
|
|
|
// Stop any previous reading first
|
|
stopReading();
|
|
|
|
var el = document.getElementById(elementId);
|
|
var text = (el.innerText || el.textContent).trim();
|
|
|
|
if (!text) {
|
|
showToast('Nothing to read', 'error');
|
|
return;
|
|
}
|
|
|
|
// Find the read button that triggered this
|
|
var readBtn = findReadButton(elementId);
|
|
|
|
// Try ElevenLabs first
|
|
fetch('/api/text-to-speech', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: text.substring(0, 5000) })
|
|
})
|
|
.then(function(response) {
|
|
if (!response.ok) throw new Error('ElevenLabs not available');
|
|
return response.blob();
|
|
})
|
|
.then(function(blob) {
|
|
var url = URL.createObjectURL(blob);
|
|
currentAudio = new Audio(url);
|
|
currentlyReadingId = elementId;
|
|
|
|
setReadButtonActive(readBtn, true);
|
|
|
|
currentAudio.onended = function() {
|
|
stopReading();
|
|
};
|
|
|
|
currentAudio.onerror = function() {
|
|
stopReading();
|
|
// Fall back to browser TTS
|
|
useBrowserTTS(text, elementId, readBtn);
|
|
};
|
|
|
|
currentAudio.play();
|
|
showToast('Playing audio — click Stop to end', 'info');
|
|
})
|
|
.catch(function() {
|
|
// Fall back to browser speech
|
|
useBrowserTTS(text, elementId, readBtn);
|
|
});
|
|
}
|
|
|
|
function useBrowserTTS(text, elementId, readBtn) {
|
|
if (!('speechSynthesis' in window)) {
|
|
showToast('Text-to-speech not supported', 'error');
|
|
return;
|
|
}
|
|
|
|
window.speechSynthesis.cancel();
|
|
|
|
var utterance = new SpeechSynthesisUtterance(text);
|
|
utterance.rate = 0.9;
|
|
utterance.pitch = 1.0;
|
|
|
|
utterance.onend = function() {
|
|
stopReading();
|
|
};
|
|
|
|
utterance.onerror = function() {
|
|
stopReading();
|
|
};
|
|
|
|
currentlyReadingId = elementId;
|
|
setReadButtonActive(readBtn, true);
|
|
|
|
window.speechSynthesis.speak(utterance);
|
|
showToast('Reading aloud — click Stop to end', 'info');
|
|
}
|
|
|
|
function stopReading() {
|
|
// Stop browser TTS
|
|
if ('speechSynthesis' in window) {
|
|
window.speechSynthesis.cancel();
|
|
}
|
|
|
|
// Stop ElevenLabs audio
|
|
if (currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio.currentTime = 0;
|
|
if (currentAudio.src && currentAudio.src.startsWith('blob:')) {
|
|
URL.revokeObjectURL(currentAudio.src);
|
|
}
|
|
currentAudio = null;
|
|
}
|
|
|
|
// Reset button state
|
|
if (currentlyReadingId) {
|
|
var readBtn = findReadButton(currentlyReadingId);
|
|
setReadButtonActive(readBtn, false);
|
|
currentlyReadingId = null;
|
|
}
|
|
}
|
|
|
|
function findReadButton(elementId) {
|
|
// Walk up from the output text to find the read button in the same card
|
|
var el = document.getElementById(elementId);
|
|
if (!el) return null;
|
|
var card = el.closest('.output-card') || el.closest('.card');
|
|
if (!card) return null;
|
|
// Find the button that calls speakText with this elementId
|
|
var buttons = card.querySelectorAll('button');
|
|
for (var i = 0; i < buttons.length; i++) {
|
|
var onclick = buttons[i].getAttribute('onclick') || '';
|
|
if (onclick.indexOf('speakText') !== -1 && onclick.indexOf(elementId) !== -1) {
|
|
return buttons[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function setReadButtonActive(btn, isActive) {
|
|
if (!btn) return;
|
|
|
|
if (isActive) {
|
|
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(displayEl) {
|
|
var secs = 0;
|
|
var interval = null;
|
|
return {
|
|
start: function() {
|
|
secs = 0;
|
|
this.update();
|
|
var self = this;
|
|
interval = setInterval(function() { secs++; self.update(); }, 1000);
|
|
},
|
|
stop: function() {
|
|
if (interval) { clearInterval(interval); interval = null; }
|
|
return secs;
|
|
},
|
|
update: function() {
|
|
var m = String(Math.floor(secs / 60)).padStart(2, '0');
|
|
var s = String(secs % 60).padStart(2, '0');
|
|
displayEl.textContent = m + ':' + s;
|
|
}
|
|
};
|
|
}
|
|
|
|
// --- 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 mimeType = 'audio/webm';
|
|
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
mimeType = 'audio/webm;codecs=opus';
|
|
}
|
|
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mimeType });
|
|
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();
|
|
});
|
|
};
|
|
|
|
console.log('✅ App.js loaded');
|