fix: recordings that produced nothing, and the boxes that zoomed on iOS
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:
parent
050a7d5241
commit
fed4bd154f
40 changed files with 205 additions and 245 deletions
42
package-lock.json
generated
42
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ────
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
1
public/vendor/katex/contrib/mhchem.min.js
vendored
Normal file
1
public/vendor/katex/contrib/mhchem.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Main-Bold.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Main-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Main-Italic.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Main-Italic.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Main-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Main-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Math-Italic.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Math-Italic.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Script-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Script-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2
vendored
Normal file
Binary file not shown.
BIN
public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2
vendored
Normal file
BIN
public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2
vendored
Normal file
Binary file not shown.
1
public/vendor/katex/katex.min.css
vendored
Normal file
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
1
public/vendor/katex/katex.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'\);/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue