fix: recordings that produced nothing, and the boxes that zoomed on iOS
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s

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
This commit is contained in:
Daniel 2026-09-11 00:16:19 +02:00
parent 050a7d5241
commit fed4bd154f
40 changed files with 205 additions and 245 deletions

42
package-lock.json generated
View file

@ -27,6 +27,7 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
"marked": "^18.0.2",
@ -2229,6 +2230,31 @@
"node": ">=18"
}
},
"node_modules/@marp-team/marp-core/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/@marp-team/marp-core/node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/@marp-team/marpit": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@marp-team/marpit/-/marpit-3.2.1.tgz",
@ -4074,12 +4100,12 @@
}
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">= 12"
"node": ">=22.12.0"
}
},
"node_modules/concat-stream": {
@ -5379,16 +5405,16 @@
}
},
"node_modules/katex": {
"version": "0.16.40",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz",
"integrity": "sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==",
"version": "0.18.7",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.7.tgz",
"integrity": "sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
"commander": "^15.0.0"
},
"bin": {
"katex": "cli.js"

View file

@ -35,6 +35,7 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"katex": "^0.18.7",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
"marked": "^18.0.2",

View file

@ -1286,9 +1286,13 @@ button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select,
outrank any plain element selector. This is one platform workaround, not a
style choice, so it overrides them all rather than being restated per
component (and per new component added later). */
/* An editable div zooms exactly like an <input>, and the transcript boxes are
all editable divs which is why every workspace tab still zoomed on focus
after the input sweep, while the calculators (plain inputs) did not. */
@media (max-width:768px) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
textarea, select { font-size:16px !important; }
textarea, select,
[contenteditable="true"], [contenteditable=""] { font-size:16px !important; }
}
/* Shared app chrome: the Assistant/Workspace switch and the menu toggle

View file

@ -76,8 +76,6 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
renderAssistantImageModelCheckboxes();
});
let imageModelsLoading = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadAssistantAdmin();
});
@ -88,21 +86,18 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
});
function updateAssistantLoadState() {
var save = document.getElementById('btn-save-assistant-config');
if (save) save.disabled = configState !== 'ready' || imageModelsLoading;
if (save) save.disabled = configState !== 'ready';
var retry = document.getElementById('btn-retry-assistant-config');
if (retry) retry.hidden = configState !== 'failed';
var status = document.getElementById('assistant-admin-status');
if (status) status.textContent = configState === 'failed' ?
'Settings load failed. Drafts are unchanged. Retry loading settings or revisit the Admin tab.' :
configState !== 'ready' ? 'Loading assistant settings...' :
imageModelsLoading ? 'Loading image models; saving is unavailable until discovery finishes.' : 'Settings ready.';
'Settings ready.';
}
function loadAssistantAdmin() {
@ -166,7 +161,6 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']);
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
renderAssistantImageModelCheckboxes();
renderAssistantImageModels([], window._assistantImageModelValue);
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
@ -191,7 +185,6 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
if (budgetMeta) budgetMeta.textContent = assistantBudgetMeta(budget, cfgBudget);
configState = 'ready';
updateAssistantLoadState();
loadAssistantImageModels();
loadAssistantPromptPoolStatus();
}).catch(function() {
configState = 'failed';
@ -201,54 +194,6 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
});
}
function renderAssistantImageModels(models, current) {
var sel = document.getElementById('assistant-image-model');
if (!sel) return;
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name || m.id;
sel.appendChild(opt);
});
if (current && !Array.prototype.some.call(sel.options, function(o) { return o.value === current; })) {
var custom = document.createElement('option');
custom.value = current;
custom.textContent = current + ' (saved/custom)';
sel.appendChild(custom);
}
sel.value = current;
}
function loadAssistantImageModels() {
var sel = document.getElementById('assistant-image-model');
if (!sel || configState !== 'ready' || imageModelsLoading) return;
// Keep the real selection, never an empty loading option. Read it again on
// completion so a newer custom selection wins over an in-flight discovery.
imageModelsLoading = true;
sel.disabled = true;
updateAssistantLoadState();
fetch('/api/admin/config/image-models/discover', { headers: getAuthHeaders() })
.then(function(r) {
if (!r.ok) throw new Error('Image discovery failed');
return r.json();
})
.then(function(data) {
if (!data || data.success === false || !Array.isArray(data.models) ||
!data.models.every(function(m) { return m && typeof m.id === 'string' && m.id; })) throw new Error('Invalid image models');
renderAssistantImageModelCheckboxes();
renderAssistantImageModels(data.models, sel.value);
})
.catch(function() {
renderAssistantImageModels(['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'].map(function(id) { return { id: id }; }), sel.value);
})
.finally(function() {
imageModelsLoading = false;
sel.disabled = false;
updateAssistantLoadState();
});
}
function testAssistantChatModel() {
var model = getValue('assistant-chat-model');
var result = document.getElementById('assistant-chat-test-result');
@ -363,41 +308,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
});
}
function testAssistantImageModel() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result');
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing ' + escAssistant(model) + '...';
fetch('/api/admin/config/image-models/test', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Image test failed');
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
showToast('Image model works', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function useCustomAssistantImageModel() {
var input = document.getElementById('assistant-custom-image-model');
var sel = document.getElementById('assistant-image-model');
var model = input ? input.value.trim() : '';
if (!model) { showToast('Enter an image model ID', 'error'); return; }
if (sel && !Array.prototype.some.call(sel.options, function(o) { return o.value === model; })) {
var opt = document.createElement('option');
opt.value = model;
opt.textContent = model + ' (custom)';
sel.appendChild(opt);
}
if (sel) sel.value = model;
window._assistantImageModelValue = model;
showToast('Custom image model selected. Save settings to keep it.', 'info');
}
function saveAssistantAdmin() {
if (configState !== 'ready' || imageModelsLoading) return;
if (configState !== 'ready') return;
var chat = document.getElementById('assistant-chat-model');
if (!chat || chat.selectedIndex < 0) return;
var status = document.getElementById('assistant-admin-status');

View file

@ -545,10 +545,6 @@ document.addEventListener('DOMContentLoaded', function() {
try { localStorage.setItem('ped_sidebar_collapsed', nowHidden ? '1' : '0'); } catch (e) {}
});
// Set footer year
var yearEl = document.getElementById('app-year');
if (yearEl) yearEl.textContent = new Date().getFullYear();
// Load settings data when settings tab is activated
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'settings') {
@ -1068,6 +1064,40 @@ window.rescueActiveRecordings = function() {
};
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
@ -1077,14 +1107,7 @@ AudioRecorder.prototype.start = function() {
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
.then(function(stream) {
self.stream = stream;
// Codec chain: Opus webm, plain webm, then mp4 (iOS Safari), then the browser default.
var options = { audioBitsPerSecond: 32000 };
var mime = '';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) mime = 'audio/webm;codecs=opus';
else if (MediaRecorder.isTypeSupported('audio/webm')) mime = 'audio/webm';
else if (MediaRecorder.isTypeSupported('audio/mp4')) mime = 'audio/mp4';
if (mime) options.mimeType = mime;
self.mediaRecorder = new MediaRecorder(stream, options);
self.mediaRecorder = 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
@ -1119,7 +1142,12 @@ AudioRecorder.prototype.notifyFailure = function() {
if (typeof showToast === 'function') showToast(message + '. Stop and check your microphone.', 'error');
} catch (e) {}
try {
document.dispatchEvent(new CustomEvent('audio-recorder-failed', { detail: { message: message } }));
// 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) {}
};

View file

@ -10,7 +10,6 @@ document.addEventListener('DOMContentLoaded', function() {
var loginForm = document.getElementById('login-form');
var registerForm = document.getElementById('register-form');
var forgotForm = document.getElementById('forgot-form');
var userName = document.getElementById('user-name');
var TOKEN_KEY = 'ped_scribe_token';
var USER_KEY = 'ped_scribe_user';
@ -455,7 +454,6 @@ document.addEventListener('DOMContentLoaded', function() {
mainApp.style.display = 'block';
mainApp.className = mainApp.className.replace('hidden', '').trim();
}
if (userName) userName.textContent = user.name || user.email;
// Show admin tab for admins only
var adminTabBtn = document.getElementById('admin-tab-btn');

View file

@ -150,18 +150,6 @@ import {
if (layout) layout.classList.remove('mobile-chats-open');
openCreateImageDialog();
});
// The button is the only affordance once the rail is gone, so it must always
// say which way it goes.
var syncHistoryToggle = function(collapsed) {
var btn = document.getElementById('btn-assistant-toggle-history');
if (!btn) return;
var label = collapsed ? 'Show saved chats' : 'Hide saved chats';
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
btn.classList.toggle('is-collapsed', !!collapsed);
};
// The rail mirrors the app's real tab list rather than restating it, so a tab
// added, renamed or hidden in index.html shows up here with no extra work.
// Clinical work only. Account and content-management tabs stay in the app
@ -297,21 +285,11 @@ import {
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
var backdrop = document.getElementById('assistant-drawer-backdrop');
if (backdrop) backdrop.addEventListener('click', closeDrawer);
// Desktop: collapse/expand the saved-chats rail (ChatGPT-style sidebar)
var toggleHistoryBtn = document.getElementById('btn-assistant-toggle-history');
if (toggleHistoryBtn) toggleHistoryBtn.addEventListener('click', function() {
var layout = document.getElementById('assistant-layout');
if (!layout) return;
var collapsed = layout.classList.toggle('history-collapsed');
syncHistoryToggle(collapsed);
try { localStorage.setItem('ped_assistant_history_collapsed', collapsed ? '1' : '0'); } catch (e) {}
});
if (typeof window !== 'undefined' && window.innerWidth > 640) {
var startCollapsed = false;
try { startCollapsed = localStorage.getItem('ped_assistant_history_collapsed') === '1'; } catch (e) {}
var layoutForPref = document.getElementById('assistant-layout');
if (layoutForPref && startCollapsed) layoutForPref.classList.add('history-collapsed');
syncHistoryToggle(startCollapsed);
}
if (form) form.addEventListener('submit', onAsk);
@ -1442,7 +1420,6 @@ import {
if (job.imageUrl) {
lastGeneratedImageSrc = job.imageUrl;
exporter.invalidate();
loadImageGallery();
try {
if (typeof hooks.onDone === 'function') hooks.onDone(job.imageUrl); else openImagePreview(job.imageUrl);
} catch (doneError) {
@ -1482,23 +1459,6 @@ import {
}).catch(function (error) { if (typeof showToast === 'function') showToast(error.message, 'error'); });
}
// ── Images gallery: everything this user has created, newest first ──
function loadImageGallery() {
var wrap = document.getElementById('assistant-image-gallery');
if (!wrap || typeof fetchAssistantImageJobs !== 'function') return;
fetchAssistantImageJobs().then(function (data) {
if (!data.success || !Array.isArray(data.jobs)) return;
var done = data.jobs.filter(function (job) { return job.imageUrl; });
if (!done.length) {
wrap.innerHTML = '<p class="assistant-muted">Generated images appear here.</p>';
return;
}
wrap.innerHTML = done.slice(0, 24).map(function (job) {
return '<button type="button" class="assistant-gallery-item" data-gallery-image="' + escapeAttr(job.imageUrl) + '" title="' + escapeAttr(new Date(job.createdAt || '').toLocaleString()) + '"><img src="' + escapeAttr(job.imageUrl) + '" data-image-thumb="256" alt="Generated image" loading="lazy"></button>';
}).join('');
}).catch(function () {});
}
function openImagePreview(src) {
if (!src) return;
var downloadUrl = src + (src.indexOf('?') === -1 ? '?download=1' : '&download=1');
@ -2137,8 +2097,6 @@ import {
lastGeneratedImageSrc = '';
generatedImageJobs = [];
imageStore.clear();
var out = document.getElementById('assistant-visual-output');
if (out) out.innerHTML = '';
exporter.invalidate();
}
@ -2148,7 +2106,6 @@ import {
renderImageJobs(row.querySelector('.assistant-bubble') || row, message.imageJobs, 'clinical_assistant', function(card, data) {
card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl));
exporter.invalidate();
loadImageGallery(); // completed images join the user's library immediately
});
}
@ -2245,7 +2202,6 @@ import {
fetchSavedAssistantChats()
.then(function (data) { if (data.success) renderSavedChats(data.chats || []); })
.catch(function () {});
loadImageGallery();
}
// Open WebUI groups saved chats by recency rather than showing one long list.
@ -2404,7 +2360,6 @@ import {
wrap.scrollTop = wrap.scrollHeight;
}
renderSources(lastSources);
loadImageGallery(); // restored/generated images live in the rail gallery now
exporter.invalidate();
updateConversationBudget();
}
@ -2523,10 +2478,6 @@ import {
function updateConversationBudget() {
var input = document.getElementById('assistant-input');
var used = conversationSize(input ? input.value : '');
var label = document.getElementById('assistant-context-budget');
if (label) label.textContent = used.toLocaleString() + (conversationChars === null ?
' conversation characters (UTF-16 code units). Limit unavailable; the server must validate each request.' :
' / ' + conversationChars.toLocaleString() + ' conversation characters (UTF-16 code units).');
var warning = document.getElementById('assistant-context-warning');
if (warning) {
warning.hidden = conversationChars === null || used * 10 < conversationChars * 9;

View file

@ -318,9 +318,12 @@
}
return transcribeAudio(blob).then(function(data) {
hideBusy();
if (data && data.success) {
if (data && data.success && data.text) {
if (transcriptEl) transcriptEl.textContent = data.text;
showToast('Transcribed ' + dur + 's', 'success');
} else if (data && data.success) {
if (_liveTranscript.trim() && transcriptEl) transcriptEl.textContent = _liveTranscript.trim();
showToast('No speech detected in the recording', 'error');
} else if (_liveTranscript.trim() && transcriptEl) {
transcriptEl.textContent = _liveTranscript.trim();
}
@ -339,15 +342,7 @@
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
if (_recognition) try { _recognition.stop(); } catch(e) {}
} else {
try {
if (_recorder.mediaRecorder.state === 'paused') { _recorder.mediaRecorder.resume(); }
else if (_recorder.mediaRecorder.state === 'inactive' && _recorder.stream && _recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
_recorder.mediaRecorder = new MediaRecorder(_recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
_recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _recorder.chunks.push(e.data); };
_recorder.mediaRecorder.start(1000);
}
} catch(e) {}
_recorder.resumeCapture();
_timer.resume(); _paused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
if (_recognition) try { _recognition.start(); } catch(e) {}

View file

@ -96,7 +96,15 @@ var _liveEncounterInited = false;
} else {
showBusy('Transcribing...');
recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { hideBusy(); if (liveText) transcript.textContent = liveText; return; }
if (!blob || blob.size === 0) {
hideBusy();
if (liveText) { transcript.textContent = liveText; return; }
// Nothing was captured and the browser heard nothing either. Saying
// so is the whole point: this used to reset the button and leave an
// empty box, which reads as "it transcribed silence".
showToast('No audio was captured — check the microphone and try again', 'error');
return;
}
if (blob.size > 24 * 1024 * 1024) {
hideBusy();
transcript.textContent = liveText;
@ -105,7 +113,10 @@ var _liveEncounterInited = false;
}
return transcribeAudio(blob).then(function(data) {
hideBusy();
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
// A successful call that returns no words must not wipe what the
// browser already heard — that looked exactly like a lost recording.
if (data.success && data.text) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
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 || 'Failed', 'error'); }
});
@ -118,8 +129,11 @@ var _liveEncounterInited = false;
// taken by another app, unplugged or revoked — takes the same path as pressing
// Stop, so whatever was captured is transcribed and stored rather than sitting
// in a tab that still says "recording".
document.addEventListener('audio-recorder-failed', function() {
document.addEventListener('audio-recorder-failed', function(e) {
if (!isRecording) return;
// Only this tab's recorder. The event is on document, so the assistant's
// microphone failing must not end the consultation being recorded here.
if (e && e.detail && e.detail.recorder && e.detail.recorder !== recorder) return;
recordBtn.style.display = '';
recordBtn.click();
});
@ -164,22 +178,9 @@ var _liveEncounterInited = false;
if (recognition) try { recognition.stop(); } catch(e) {}
showToast('Recording paused', 'info');
} else {
// Resume: try MediaRecorder.resume(), fall back to just restarting recognition
if (recorder.mediaRecorder) {
try {
if (typeof recorder.mediaRecorder.resume === 'function' && recorder.mediaRecorder.state === 'paused') {
recorder.mediaRecorder.resume();
} else if (recorder.mediaRecorder.state === 'inactive') {
// MediaRecorder was stopped (browser killed it) — restart it on the same stream
if (recorder.stream && recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
recorder.mediaRecorder.start(1000);
}
}
} catch(e) { console.warn('[Rec] Resume error:', e.message); }
}
// Resume: the recorder resumes, or is rebuilt on the same stream if the
// browser stopped it outright. Recognition restarts either way.
recorder.resumeCapture();
timer.resume();
isPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';

View file

@ -304,15 +304,7 @@ function setupShadessModule() {
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
if (_shhadessRecognition) try { _shhadessRecognition.stop(); } catch(e) {}
} else {
try {
if (_shadessRecorder.mediaRecorder.state === 'paused') { _shadessRecorder.mediaRecorder.resume(); }
else if (_shadessRecorder.mediaRecorder.state === 'inactive' && _shadessRecorder.stream && _shadessRecorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
_shadessRecorder.mediaRecorder = new MediaRecorder(_shadessRecorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
_shadessRecorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _shadessRecorder.chunks.push(e.data); };
_shadessRecorder.mediaRecorder.start(1000);
}
} catch(e) {}
_shadessRecorder.resumeCapture();
_shadessTimer.resume();
_shadessIsPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
@ -758,7 +750,8 @@ function setupShadessModule() {
if (!blob || blob.size === 0) { hideBusy(); if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); return; }
return transcribeAudio(blob).then(function(data) {
hideBusy();
if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _wvTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
if (data.success && data.text) { if (transcriptEl) transcriptEl.textContent = data.text; _wvTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else if (data.success) { if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); showToast('No speech detected in the recording', 'error'); }
else { if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); }
});
}).catch(function() { hideBusy(); });
@ -774,15 +767,7 @@ function setupShadessModule() {
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
if (_wvRecognition) try { _wvRecognition.stop(); } catch(e) {}
} else {
try {
if (_wvRecorder.mediaRecorder.state === 'paused') { _wvRecorder.mediaRecorder.resume(); }
else if (_wvRecorder.mediaRecorder.state === 'inactive' && _wvRecorder.stream && _wvRecorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
_wvRecorder.mediaRecorder = new MediaRecorder(_wvRecorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
_wvRecorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _wvRecorder.chunks.push(e.data); };
_wvRecorder.mediaRecorder.start(1000);
}
} catch(e) {}
_wvRecorder.resumeCapture();
_wvTimer.resume(); _wvPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
if (_wvRecognition) try { _wvRecognition.start(); } catch(e) {}

View file

@ -179,7 +179,8 @@
if (!blob || blob.size === 0) { hideBusy(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; }
return transcribeAudio(blob).then(function(data) {
hideBusy();
if (data.success) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
if (data.success && data.text) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else if (data.success) { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); showToast('No speech detected in the recording', 'error'); }
else { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); }
});
}).catch(function() { hideBusy(); });
@ -195,15 +196,7 @@
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
} else {
try {
if (_sickRecorder.mediaRecorder.state === 'paused') { _sickRecorder.mediaRecorder.resume(); }
else if (_sickRecorder.mediaRecorder.state === 'inactive' && _sickRecorder.stream && _sickRecorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
_sickRecorder.mediaRecorder = new MediaRecorder(_sickRecorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
_sickRecorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _sickRecorder.chunks.push(e.data); };
_sickRecorder.mediaRecorder.start(1000);
}
} catch(e) {}
_sickRecorder.resumeCapture();
_sickTimer.resume(); _sickPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}

View file

@ -99,7 +99,8 @@
}
return transcribeAudio(blob).then(function(data) {
hideBusy();
if (data.success) transcript.textContent = data.text;
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'); }
});
@ -131,15 +132,7 @@
if (recognition) try { recognition.stop(); } catch(e) {}
showToast('Paused', 'info');
} else {
try {
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'paused') { recorder.mediaRecorder.resume(); }
else if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'inactive' && recorder.stream && recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
recorder.mediaRecorder.start(1000);
}
} catch(e) {}
recorder.resumeCapture();
timer.resume();
isPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';

View file

@ -89,7 +89,8 @@ var _voiceDictationInited = false;
}
return transcribeAudio(blob).then(function(data) {
hideBusy();
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
if (data.success && data.text) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
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'); }
});
@ -112,15 +113,7 @@ var _voiceDictationInited = false;
if (recognition) try { recognition.stop(); } catch(e) {}
showToast('Paused', 'info');
} else {
try {
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'paused') { recorder.mediaRecorder.resume(); }
else if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'inactive' && recorder.stream && recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
recorder.mediaRecorder.start(1000);
}
} catch(e) {}
recorder.resumeCapture();
timer.resume();
isPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
public/vendor/katex/katex.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
public/vendor/katex/katex.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -182,6 +182,10 @@ app.get(['/', '/index.html', '/assistant'], function(req, res) {
// Public endpoint for cache-bust debugging + build-info
app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
// Registers the citation-quality counters at boot. Without this they appear
// only after the first answer, so a fresh Grafana panel reads "no data"
// instead of zero — which looks like broken wiring rather than a quiet day.
require('./src/utils/citationAudit');
app.get('/metrics', metricsHandler);
app.use(loggingMiddleware);

View file

@ -17,8 +17,26 @@ var client = require('prom-client');
// Registering a name twice throws, and this module can legitimately be loaded
// more than once in one process — a test harness that clears the require cache
// to inject stubs does exactly that. Reuse whatever is already registered.
// The app serves its own registry, not prom-client's default one, so a counter
// registered anywhere else is simply never scraped — which is how these first
// went live invisible. Harnesses that stub the metrics module get the default
// registry instead; they are not scraping anything, they only need inc() to work.
function registry() {
try {
var metrics = require('./metrics');
if (metrics && metrics.register && typeof metrics.register.getSingleMetric === 'function') {
return metrics.register;
}
} catch (e) {
// no metrics module in this process
}
return client.register;
}
function counter(name, help) {
return client.register.getSingleMetric(name) || new client.Counter({ name: name, help: help });
var target = registry();
return target.getSingleMetric(name) ||
new client.Counter({ name: name, help: help, registers: [target] });
}
var answersTotal = counter(

View file

@ -177,8 +177,15 @@ test('no text control is small enough to make iOS zoom the page', () => {
// screen after typing. 16px is the threshold.
const rule = css.slice(css.indexOf('/* iOS Safari zooms the whole page'));
assert.match(rule, /@media \(max-width:768px\) \{\s*\n\s*input:not\(\[type="checkbox"\]\)/);
assert.match(rule, /textarea, select \{ font-size:16px !important; \}/,
assert.match(rule, /font-size:16px !important; \}/,
'it must beat per-component rules such as .demo-field select');
// An editable div zooms exactly like an input. Every transcript box in the
// workspace is one, so leaving them out meant every tab still zoomed on focus
// while the calculators (plain inputs) did not.
assert.match(rule, /\[contenteditable="true"\], \[contenteditable=""\]/,
'the contenteditable transcript boxes need it too');
assert.match(read('public/css/styles.css'), /\.editable-box\{[^}]*font-size:13px/,
'the boxes are 13px by default, which is what makes the override necessary');
// Pinch-zoom stays available: the fix is the font size, not a locked viewport.
assert.doesNotMatch(read('public/index.html'), /user-scalable=no|maximum-scale=1/);
// The composer set 15px AFTER its own mobile override, so the base rule won.

View file

@ -142,7 +142,14 @@ test('a recording that ends by itself is still transcribed, and logging out stop
const live = read('public/js/liveEncounter.js');
// Same path as pressing Stop, so the audio is transcribed and stored rather
// than left in a tab that still claims to be recording.
assert.match(live, /document\.addEventListener\('audio-recorder-failed', function\(\) \{[\s\S]{0,160}recordBtn\.click\(\);/);
assert.match(live, /document\.addEventListener\('audio-recorder-failed', function\(e\) \{[\s\S]{0,320}recordBtn\.click\(\);/);
// The event is on document, so it reaches every listener. Without checking
// which recorder failed, the assistant's microphone dying ended the
// consultation being recorded in this tab.
assert.match(live, /if \(e && e\.detail && e\.detail\.recorder && e\.detail\.recorder !== recorder\) return;/,
'only this tab\'s own recorder may stop this tab\'s recording');
assert.match(read('public/js/app.js'), /detail: \{ message: message, recorder: this \}/,
'so the recorder has to travel with the event');
assert.match(live, /window\.addEventListener\('account-boundary', function\(\) \{[\s\S]{0,200}recorder\.stop\(\)/,
'signing out mid-recording stops it');
});
@ -206,3 +213,44 @@ test('switching to the Assistant does not reload the page out from under a recor
// activateTab already rewrites the URL, so the address still reads /assistant.
assert.match(app, /var target = tabName === 'assistant' \? '\/assistant' : '\/';/);
});
test('one codec chain, so Safari can record at all', () => {
const app = read('public/js/app.js');
// Safari supports none of the webm types and throws NotSupportedError when
// handed one. Every module used to build its own recorder on resume with
// "opus, else audio/webm" — which meant resuming a recording threw there.
assert.match(app, /var candidates = \['audio\/webm;codecs=opus', 'audio\/webm', 'audio\/mp4'\];/);
assert.match(app, /AudioRecorder\.prototype\.resumeCapture = function\(\)/);
assert.match(app, /self\.mediaRecorder = new MediaRecorder\(self\.stream, AudioRecorder\.encoding\(\)\);/,
'the rebuild on resume uses the same chain');
for (const file of ['liveEncounter', 'soap', 'voiceDictation', 'ed-encounters', 'sickVisit', 'shadess']) {
const source = read('public/js/' + file + '.js');
assert.doesNotMatch(source, /new MediaRecorder\(/,
file + ' must not build a recorder of its own');
assert.match(source, /resumeCapture\(\)/, file + ' resumes through the shared recorder');
}
});
test('a transcription that finds no words leaves the live transcript alone', () => {
// The server answers {success:true, text:''} for silence. Assigning that
// emptied the box the browser had been filling all along, which reads as a
// recording that vanished.
const cases = [
['liveEncounter', /if \(data\.success && data\.text\) \{ transcript\.textContent = data\.text;/],
['voiceDictation', /if \(data\.success && data\.text\) \{ transcript\.textContent = data\.text;/],
['soap', /if \(data\.success && data\.text\) transcript\.textContent = data\.text;/],
['sickVisit', /if \(data\.success && data\.text\) \{ if \(transcriptEl\)/],
['shadess', /if \(data\.success && data\.text\) \{ if \(transcriptEl\)/],
['ed-encounters', /if \(data && data\.success && data\.text\) \{/]
];
for (const [file, pattern] of cases) {
const source = read('public/js/' + file + '.js');
assert.match(source, pattern, file + ' must require text before overwriting');
assert.match(source, /No speech detected in the recording/, file + ' says so instead of blanking');
}
// And a recording that captured nothing at all has to say that much, rather
// than resetting the button and leaving an empty box.
assert.match(read('public/js/liveEncounter.js'),
/showToast\('No audio was captured — check the microphone and try again', 'error'\);/);
});