Measured in a real browser against the app rather than reasoned about.
The transcript boxes are contenteditable divs, and an editable div zooms on
focus exactly like an <input>. The earlier 16px sweep covered input, textarea
and select, so every workspace tab still zoomed while the calculators did not —
which is exactly what was reported. Every focusable text control in every tab
now measures 16px at phone width; the count of ones below it is zero.
Three ways a recording could end with nothing to show for it:
- Safari supports none of the audio/webm types and throws NotSupportedError
when handed one. Six modules built their own recorder on resume with
"opus, else audio/webm", so resuming threw there and the recording stopped.
There is now one codec chain in the app, and no module constructs a
MediaRecorder of its own.
- audio-recorder-failed is dispatched on document, and the encounter tab
stopped its recording on any of them. The assistant's microphone failing
ended a consultation being recorded in another tab. The recorder now
travels with the event and the listener checks it is its own.
- The server answers {success:true, text:''} for silence, and five modules
assigned that straight into the transcript — emptying the box the browser
had been filling live. It reads as a recording that vanished. Text is now
required before overwriting, and a recording that captured nothing says so
instead of resetting the button over an empty box.
Also: the citation counters were registered on prom-client's default registry
while the app serves its own, so they were never scraped. They read zero at
/metrics now instead of being absent, which is what the Grafana panels need.
And the reference linter passes for the first time, so scripts/e2e.sh gets past
its preflight: KaTeX is vendored (it was referenced by the assistant's LaTeX
rendering but never shipped — three 404s a page load and no math), and the
JavaScript left behind by the removed image picker, saved-chats toggle, image
gallery and visual-output panel is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
218 lines
9.6 KiB
JavaScript
218 lines
9.6 KiB
JavaScript
var _inited = false;
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail.tab !== 'soap' || _inited) return;
|
|
_inited = true;
|
|
var recordBtn = document.getElementById('soap-record-btn');
|
|
var indicator = document.getElementById('soap-recording-indicator');
|
|
var timerEl = document.getElementById('soap-timer');
|
|
var transcript = document.getElementById('soap-transcript');
|
|
var clearBtn = document.getElementById('soap-clear');
|
|
var generateBtn = document.getElementById('soap-generate-btn');
|
|
var outputCard = document.getElementById('soap-output');
|
|
var soapText = document.getElementById('soap-text');
|
|
var modelTag = document.getElementById('soap-model-tag');
|
|
|
|
var pauseBtn = document.getElementById('soap-pause-btn');
|
|
var stopBtn = document.getElementById('soap-stop-btn');
|
|
|
|
var recorder = new AudioRecorder();
|
|
recorder._module = 'soap';
|
|
var timer = createTimer(timerEl);
|
|
var isRecording = false;
|
|
var isPaused = false;
|
|
var recognition = createSpeechRecognition();
|
|
var finalText = '';
|
|
var sessionFinals = '';
|
|
|
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
|
if (recognition) {
|
|
recognition.onresult = function(e) {
|
|
var interim = '';
|
|
for (var i = e.resultIndex; i < e.results.length; i++) {
|
|
if (e.results[i].isFinal) {
|
|
var chunk = e.results[i][0].transcript + ' ';
|
|
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
|
sessionFinals += deduped;
|
|
} else {
|
|
interim = e.results[i][0].transcript;
|
|
}
|
|
}
|
|
var combined = finalText + sessionFinals;
|
|
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
|
};
|
|
recognition.onend = function() {
|
|
finalText += sessionFinals;
|
|
sessionFinals = '';
|
|
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
|
|
};
|
|
}
|
|
|
|
recordBtn.addEventListener('click', function() {
|
|
if (!isRecording) {
|
|
finalText = '';
|
|
sessionFinals = '';
|
|
recorder.start().then(function() {
|
|
isRecording = true;
|
|
isPaused = false;
|
|
recordBtn.classList.add('recording');
|
|
recordBtn.style.display = 'none';
|
|
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
|
if (stopBtn) stopBtn.classList.remove('hidden');
|
|
indicator.classList.remove('hidden');
|
|
timer.start();
|
|
if (recognition) try { recognition.start(); } catch(e) {}
|
|
if (typeof nativeHaptic === 'function') nativeHaptic('heavy');
|
|
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(true);
|
|
if (typeof nativeStartRecordingService === 'function') nativeStartRecordingService();
|
|
showToast('Recording started', 'info');
|
|
}).catch(function() { showToast('Microphone denied', 'error'); });
|
|
} else {
|
|
isRecording = false;
|
|
isPaused = false;
|
|
var dur = timer.stop();
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.style.display = '';
|
|
recordBtn.querySelector('span').textContent = 'Dictate';
|
|
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; pauseBtn.classList.remove('btn-primary'); pauseBtn.classList.add('btn-ghost'); }
|
|
if (stopBtn) stopBtn.classList.add('hidden');
|
|
indicator.classList.add('hidden');
|
|
if (recognition) try { recognition.stop(); } catch(e) {}
|
|
if (typeof nativeHaptic === 'function') nativeHaptic('medium');
|
|
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(false);
|
|
if (typeof nativeStopRecordingService === 'function') nativeStopRecordingService();
|
|
|
|
var liveText = (finalText + sessionFinals).trim();
|
|
if (window._transcribeAvailable === false) {
|
|
recorder.stop().then(function() {});
|
|
if (liveText) transcript.textContent = liveText;
|
|
showToast('Using browser speech recognition', 'info');
|
|
} else {
|
|
showBusy('Transcribing...');
|
|
recorder.stop().then(function(blob) {
|
|
if (!blob) { hideBusy(); if (liveText) transcript.textContent = liveText; return; }
|
|
if (blob.size > 24 * 1024 * 1024) {
|
|
hideBusy();
|
|
transcript.textContent = liveText;
|
|
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
|
return;
|
|
}
|
|
return transcribeAudio(blob).then(function(data) {
|
|
hideBusy();
|
|
if (data.success && data.text) transcript.textContent = data.text;
|
|
else if (data.success) { if (liveText) transcript.textContent = liveText; showToast('No speech detected in the recording', 'error'); }
|
|
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
|
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
|
|
});
|
|
}).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Dedicated stop button
|
|
if (stopBtn) {
|
|
stopBtn.addEventListener('click', function() {
|
|
if (!isRecording) return;
|
|
recordBtn.style.display = '';
|
|
recordBtn.click();
|
|
});
|
|
}
|
|
|
|
// Pause / Resume
|
|
if (pauseBtn) {
|
|
pauseBtn.addEventListener('click', function() {
|
|
if (!isRecording) return;
|
|
if (!isPaused) {
|
|
try { if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'recording') recorder.mediaRecorder.pause(); } catch(e) {}
|
|
timer.stop();
|
|
isPaused = true;
|
|
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
|
pauseBtn.classList.add('btn-primary');
|
|
pauseBtn.classList.remove('btn-ghost');
|
|
if (recognition) try { recognition.stop(); } catch(e) {}
|
|
showToast('Paused', 'info');
|
|
} else {
|
|
recorder.resumeCapture();
|
|
timer.resume();
|
|
isPaused = false;
|
|
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
|
pauseBtn.classList.remove('btn-primary');
|
|
pauseBtn.classList.add('btn-ghost');
|
|
if (recognition) try { recognition.start(); } catch(e) {}
|
|
showToast('Resumed', 'info');
|
|
}
|
|
});
|
|
}
|
|
|
|
clearBtn.addEventListener('click', function() {
|
|
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
|
var instrEl = document.getElementById('soap-instructions');
|
|
if (instrEl) instrEl.value = '';
|
|
window._savedEncId_soap = null;
|
|
var labelEl = document.getElementById('soap-label');
|
|
if (labelEl) labelEl.value = '';
|
|
});
|
|
|
|
generateBtn.addEventListener('click', function() {
|
|
var text = transcript.innerText.trim();
|
|
if (!text) { showToast('No input', 'error'); return; }
|
|
|
|
showBusy('Generating SOAP note...');
|
|
|
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
|
|
|
memoriesPromise.then(function(memCtx) {
|
|
return fetch('/api/generate-soap', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({
|
|
transcript: text,
|
|
patientAge: document.getElementById('soap-age').value,
|
|
patientGender: document.getElementById('soap-gender').value,
|
|
type: document.getElementById('soap-type').value,
|
|
additionalInstructions: document.getElementById('soap-instructions').value,
|
|
physicianMemories: memCtx || null,
|
|
model: getSelectedModel()
|
|
})
|
|
});
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideBusy();
|
|
if (data.success) {
|
|
setOutputText(soapText, data.soap);
|
|
storeSourceContext('soap-text', transcript.innerText.trim());
|
|
modelTag.textContent = (data.model || '').split('/').pop();
|
|
outputCard.classList.remove('hidden');
|
|
outputCard.scrollIntoView({ behavior: 'smooth' });
|
|
showToast('SOAP note generated!', 'success');
|
|
if (typeof attachPatientEducation === 'function') attachPatientEducation('soap-text', { patientAge: document.getElementById('soap-age').value });
|
|
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('soap-text', data.soap, 'soap', document.getElementById('soap-age').value);
|
|
} else showToast(data.error || 'Failed', 'error');
|
|
})
|
|
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
|
});
|
|
|
|
document.getElementById('soap-refine-btn').addEventListener('click', function() { refineDocument('soap-text', 'soap-refine-input'); });
|
|
document.getElementById('soap-shorten-btn').addEventListener('click', function() { shortenDocument('soap-text'); });
|
|
|
|
// Register load handler for resuming saved SOAP notes
|
|
if (typeof registerEncounterLoadHandler === 'function') {
|
|
registerEncounterLoadHandler('soap', function(enc) {
|
|
if (enc.transcript) transcript.textContent = enc.transcript;
|
|
if (enc.generated_note) {
|
|
setOutputText(soapText, enc.generated_note);
|
|
outputCard.classList.remove('hidden');
|
|
if (typeof attachPatientEducation === 'function') attachPatientEducation('soap-text', { patientAge: document.getElementById('soap-age').value });
|
|
}
|
|
try {
|
|
var pd = JSON.parse(enc.partial_data || '{}');
|
|
if (pd.age) document.getElementById('soap-age').value = pd.age;
|
|
if (pd.gender) document.getElementById('soap-gender').value = pd.gender;
|
|
if (pd.type) document.getElementById('soap-type').value = pd.type;
|
|
} catch(e) {}
|
|
});
|
|
}
|
|
|
|
console.log('✅ SOAP module loaded');
|
|
});
|