Compare commits

..

No commits in common. "main" and "v7.14.9" have entirely different histories.

44 changed files with 235 additions and 976 deletions

View file

@ -103,7 +103,8 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
{ {
"email": "string", "email": "string",
"password": "string", "password": "string",
"totpCode": "string (optional, required if 2FA is enabled)" "totpCode": "string (optional, required if 2FA is enabled)",
"turnstileToken": "string"
} }
``` ```
- **Response:** - **Response:**

View file

@ -123,23 +123,9 @@ necessary UX tradeoff over perfect indistinguishability.
## Turnstile (Cloudflare bot protection) ## Turnstile (Cloudflare bot protection)
Applied to `/api/auth/register` and `/api/auth/forgot-password` when Applied to `/api/auth/login`, `/register`, `/forgot-password` when
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode). `TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
`/api/auth/login` is deliberately **not** gated: the widget could not
reliably complete a challenge inside the Capacitor WebView, which locked
mobile users out of the app. Login is covered instead by its per-IP rate
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
the first time their form becomes visible — Turnstile does not reliably
complete a challenge inside a `display:none` container, and both forms start
hidden. Tokens are captured from the render callback, not read back out of
the injected `[name="cf-turnstile-response"]` input.
Note that the site key is currently **hardcoded** in `public/index.html`.
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
## Encryption at rest ## Encryption at rest
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from `src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from

View file

@ -2,15 +2,14 @@
// SESSION PERSISTENCE — full logout → login → still on the same // SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill. // tab + same sub-pill.
// //
// The test does a programmatic logout (clear the ped_auth cookie, // The UI's login form is gated by a Cloudflare Turnstile token
// same effect server-side as clicking Logout) followed by a fresh // whose site key is hardcoded in index.html, which can't be
// programmatic login. This exercises the same localStorage // completed in the e2e container (Turnstile rejects the non-prod
// persistence path a real logout/login would. // origin). So the test does a programmatic logout (clear the
// // ped_auth cookie, same effect server-side as clicking Logout)
// (Historically this was a workaround for the Turnstile challenge on // followed by a fresh programmatic login — this exercises the
// the login form, which could not be completed in the e2e container. // same localStorage persistence path a real logout/login would,
// Login is no longer gated, but driving it programmatically keeps // without depending on the bot challenge.
// the test focused on persistence rather than form mechanics.)
// ============================================================ // ============================================================
const { test, expect, E2E_BASE, loginAs } = require('../fixtures'); const { test, expect, E2E_BASE, loginAs } = require('../fixtures');

View file

@ -9,8 +9,8 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
// Version values below are overwritten by scripts/release.sh from // Version values below are overwritten by scripts/release.sh from
// the root package.json. versionCode auto-increments per release. // the root package.json. versionCode auto-increments per release.
versionCode 714016 versionCode 714009
versionName "7.14.16" versionName "7.14.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -15,8 +15,6 @@ import android.print.PrintDocumentAdapter;
import android.print.PrintManager; import android.print.PrintManager;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Base64; import android.util.Base64;
import android.view.WindowManager;
import android.webkit.CookieManager;
import android.webkit.PermissionRequest; import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient; import android.webkit.WebChromeClient;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
@ -38,11 +36,6 @@ public class MainActivity extends BridgeActivity {
private PermissionRequest pendingPermissionRequest; private PermissionRequest pendingPermissionRequest;
private WebView printWebView; private WebView printWebView;
// True between startForegroundService() and stopForegroundService(), i.e.
// while the web app has an active MediaRecorder. Drives the keep-screen-on
// flag and the timer-throttling workaround below.
private volatile boolean recordingActive = false;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@ -54,9 +47,6 @@ public class MainActivity extends BridgeActivity {
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE); new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
} }
// Allow the Cloudflare Turnstile iframe to use storage.
setupThirdPartyCookies();
// Setup WebView mic permission granting // Setup WebView mic permission granting
setupWebViewPermissions(); setupWebViewPermissions();
@ -70,79 +60,6 @@ public class MainActivity extends BridgeActivity {
setupFileBridge(); setupFileBridge();
} }
// Recording Lifecycle
//
// Recording happens in the WebView (MediaRecorder), not in native code,
// so keeping the foreground service alive is necessary but not sufficient
// the WebView also has to keep executing JS. Two things protect that:
//
// 1. FLAG_KEEP_SCREEN_ON while recording, so the device does not
// auto-lock mid-encounter. This is the case that actually bites
// clinicians: a long pause in conversation and the screen times out.
//
// 2. resumeTimers() if the activity is paused anyway (user presses the
// power button, or a call comes in). Chromium throttles timers hard
// for hidden WebViews, which starves MediaRecorder's chunk delivery.
// Capacitor never calls webView.onPause(), so the WebView itself is
// still live it is only the timers that need rescuing.
//
// Note resumeTimers()/pauseTimers() are process-global in WebView, not
// per-instance; calling resume here is safe because this app has no other
// WebView that wants throttling (printWebView is transient).
void setKeepScreenOn(final boolean on) {
runOnUiThread(() -> {
if (on) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
});
}
void setRecordingActive(boolean active) {
recordingActive = active;
setKeepScreenOn(active);
}
// NB: BridgeActivity declares these public narrowing to protected would
// not compile.
@Override
public void onPause() {
super.onPause();
if (recordingActive && this.bridge != null && this.bridge.getWebView() != null) {
this.bridge.getWebView().resumeTimers();
}
}
@Override
public void onResume() {
super.onResume();
if (this.bridge != null && this.bridge.getWebView() != null) {
this.bridge.getWebView().resumeTimers();
}
}
// Third-Party Cookies
//
// Android WebView blocks third-party cookies by default (unlike Chrome,
// which still allows them for now). Cloudflare Turnstile runs inside a
// cross-origin iframe from challenges.cloudflare.com and needs its own
// storage to run and persist a challenge without this the widget
// silently stalls or errors and never emits a token, so registration and
// password reset are impossible from inside the app.
//
// This is scoped to our own WebView, which only ever loads the PedScribe
// origin (see allowNavigation in capacitor.config.json), so it is not a
// general relaxation of the app's cookie policy.
private void setupThirdPartyCookies() {
WebView webView = this.bridge.getWebView();
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, true);
}
// WebView Microphone Permission // WebView Microphone Permission
private void setupWebViewPermissions() { private void setupWebViewPermissions() {
@ -207,7 +124,6 @@ public class MainActivity extends BridgeActivity {
public void startForegroundService() { public void startForegroundService() {
Intent intent = new Intent(activity, AudioRecordingService.class); Intent intent = new Intent(activity, AudioRecordingService.class);
ContextCompat.startForegroundService(activity, intent); ContextCompat.startForegroundService(activity, intent);
activity.setRecordingActive(true);
} }
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
@ -215,17 +131,6 @@ public class MainActivity extends BridgeActivity {
Intent intent = new Intent(activity, AudioRecordingService.class); Intent intent = new Intent(activity, AudioRecordingService.class);
intent.setAction(AudioRecordingService.ACTION_STOP); intent.setAction(AudioRecordingService.ACTION_STOP);
activity.startService(intent); activity.startService(intent);
activity.setRecordingActive(false);
}
// Standalone keep-awake, exposed so the web app can hold the screen on
// for non-recording work too. window.nativeKeepAwake() previously
// called Capacitor's KeepAwake plugin, which is not installed in this
// project so it silently did nothing and the screen slept during
// recordings.
@android.webkit.JavascriptInterface
public void keepAwake(boolean on) {
activity.setKeepScreenOn(on);
} }
} }

View file

@ -13,7 +13,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@color/colorPrimary</item> <item name="android:background">@null</item>
<item name="android:statusBarColor">@color/colorPrimaryDark</item> <item name="android:statusBarColor">@color/colorPrimaryDark</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark</item> <item name="android:navigationBarColor">@color/colorPrimaryDark</item>
</style> </style>

View file

@ -1,12 +1,12 @@
{ {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.14", "version": "7.14.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.14", "version": "7.14.9",
"dependencies": { "dependencies": {
"@aparajita/capacitor-biometric-auth": "^8.0.0", "@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/android": "^6.0.0", "@capacitor/android": "^6.0.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.16", "version": "7.14.9",
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe", "description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
"private": true, "private": true,
"scripts": { "scripts": {

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.14", "version": "7.14.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.14", "version": "7.14.9",
"dependencies": { "dependencies": {
"@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0", "@marp-team/marp-core": "^4.3.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.16", "version": "7.14.9",
"description": "AI-powered pediatric clinical documentation platform", "description": "AI-powered pediatric clinical documentation platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -318,17 +318,6 @@
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button> <button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
</div> </div>
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div> <div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
<div>
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool</div>
<div id="assistant-prompt-pool-status" style="font-size:12px;color:var(--g500);margin-top:3px;">Checking prompt pool...</div>
</div>
<button id="btn-regenerate-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Regenerate Pool</button>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%;">
<select id="assistant-prompt-pool-snapshots" style="font-size:12px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:220px;"><option value="">Loading snapshots...</option></select>
<button id="btn-restore-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-clock-rotate-left"></i> Restore Snapshot</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label> <label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select> <select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>

View file

@ -11,9 +11,6 @@
<button class="wv-subtab-btn" data-subtab="milestones"> <button class="wv-subtab-btn" data-subtab="milestones">
<i class="fas fa-baby"></i> Milestones <i class="fas fa-baby"></i> Milestones
</button> </button>
<button class="wv-subtab-btn" data-subtab="lincoln">
<i class="fas fa-clipboard-list"></i> Lincoln
</button>
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;"> <button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
<i class="fas fa-brain"></i> SSHADESS (12+) <i class="fas fa-brain"></i> SSHADESS (12+)
</button> </button>
@ -122,65 +119,6 @@
</div> </div>
<!-- Lincoln quick-reference sub-panel -->
<div id="wv-panel-lincoln" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;">
<div class="card-header output-header">
<h3><i class="fas fa-clipboard-list"></i> Lincoln Well-Child Quick Reference</h3>
<div class="output-actions">
<button class="btn-sm btn-primary" data-action="copy" data-target="wv-lincoln-reference"><i class="fas fa-copy"></i> Copy</button>
</div>
</div>
<div id="wv-lincoln-reference" class="wv-lincoln-reference">
<div class="wv-lincoln-grid">
<section class="wv-lincoln-card">
<h4>Infancy</h4>
<ul>
<li><strong>Newborn:</strong> POC visit; check for jaundice.</li>
<li><strong>2 weeks:</strong> weight gain, newborn screen, umbilicus check.</li>
<li><strong>1 month:</strong> maternal PHQ-9.</li>
<li><strong>2 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>4 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>6 months:</strong> routine vaccines and Prevnar; confirm rotavirus eligibility by product and age.</li>
<li><strong>9 months:</strong> SWYC; no routine vaccines noted.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Toddler / Preschool</h4>
<ul>
<li><strong>12 months:</strong> MMR, varicella, Hep A; CBC and lead.</li>
<li><strong>15 months:</strong> Pentacel, Prevnar, influenza.</li>
<li><strong>18 months:</strong> POSI, SWYC; Hep A second dose.</li>
<li><strong>2 years:</strong> POSI/SWYC; CBC and lead.</li>
<li><strong>3 years:</strong> blood pressure check and vision screening; BP is commonly missed and can be added on diagnosis.</li>
<li><strong>4 years:</strong> hearing and vision start; ProQuad and Kinrix.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>School Age / Adolescence</h4>
<ul>
<li><strong>Lipid screening:</strong> AAP screening at 9-11 years and 17-21 years.</li>
<li><strong>Depression screening:</strong> begin at 12 years and older.</li>
<li><strong>MenB:</strong> discuss Bexsero/MenB at 16-23 years, preferably 16-18 years, when chosen or indicated.</li>
<li><strong>Age &ge;18 years:</strong> Hep C testing.</li>
<li><strong>Cervical cancer screening:</strong> start Pap smear screening at 21 years.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Catch-Up / Screening Reminders</h4>
<ul>
<li><strong>Influenza:</strong> if a child 6 months through 8 years needs 2 doses, give doses 4 weeks apart.</li>
<li><strong>Lead:</strong> continue lead screening reminders through age 6 years; add diagnosis when needed.</li>
</ul>
</section>
</div>
</div>
</div>
</div>
<!-- SSHADESS sub-panel (age 12+) --> <!-- SSHADESS sub-panel (age 12+) -->
<div id="wv-panel-shadess" class="wv-subpanel hidden"> <div id="wv-panel-shadess" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;"> <div class="card" style="margin-bottom:10px;">
@ -367,3 +305,4 @@
</div> </div>
</div> </div>
</div> </div>

View file

@ -397,21 +397,6 @@ textarea.full-input{resize:vertical;}
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;} .wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
.wv-section-title i{color:var(--blue);} .wv-section-title i{color:var(--blue);}
/* Lincoln quick reference */
.wv-lincoln-reference{padding:14px 16px;background:var(--g50);}
.wv-lincoln-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px;}
.wv-lincoln-card{border:1px solid var(--g100);border-radius:12px;padding:14px 16px;background:white;box-shadow:0 1px 2px rgba(15,23,42,0.04);}
.wv-lincoln-card h4{margin:0 0 10px;font-size:14px;color:var(--g800);}
.wv-lincoln-card ul{margin:0;padding-left:18px;color:var(--g700);font-size:13px;line-height:1.65;}
.wv-lincoln-card li{margin-bottom:6px;}
.wv-lincoln-card li:last-child{margin-bottom:0;}
@media(max-width:640px){
.wv-lincoln-reference{padding:10px;}
.wv-lincoln-grid{grid-template-columns:1fr;gap:10px;}
.wv-lincoln-card{padding:12px;}
.wv-lincoln-card ul{font-size:12.5px;line-height:1.55;}
}
/* Billing */ /* Billing */
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;} .wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
.wv-billing-cell{display:flex;align-items:center;gap:8px;} .wv-billing-cell{display:flex;align-items:center;gap:8px;}

View file

@ -11,11 +11,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer"> integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
<!-- Explicit render mode: the register/forgot widgets live inside forms that <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
start hidden, and Turnstile's implicit auto-render does not reliably
complete a challenge inside a display:none container. auth.js renders
each widget the first time its form is shown. -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script> crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
@ -74,6 +70,7 @@
<label>2FA Code</label> <label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6"> <input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</div> </div>
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button> <button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;"> <div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span> <span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
@ -107,7 +104,7 @@
<label>Password (8+ characters)</label> <label>Password (8+ characters)</label>
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••"> <input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
</div> </div>
<div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div> <div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth">Create Account</button> <button type="submit" class="btn-auth">Create Account</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login">Back to sign in</a> <a href="#" id="show-login">Back to sign in</a>
@ -121,7 +118,7 @@
<label>Email</label> <label>Email</label>
<input type="email" id="forgot-email" required placeholder="your@email.com"> <input type="email" id="forgot-email" required placeholder="your@email.com">
</div> </div>
<div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div> <div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth">Send Reset Link</button> <button type="submit" class="btn-auth">Send Reset Link</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login-2">Back to sign in</a> <a href="#" id="show-login-2">Back to sign in</a>
@ -134,7 +131,7 @@
</div> </div>
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;"> <div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
<a href="https://git.danvics.com/danvics/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;"> <a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;">
<i class="fas fa-mobile-screen"></i> Download Android app (APK) <i class="fas fa-mobile-screen"></i> Download Android app (APK)
</a> </a>
</div> </div>

View file

@ -701,8 +701,6 @@ function adminFlashButtonBackground(btn, color) {
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin(); if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel(); 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-refresh-assistant-image-models')) loadAssistantImageModels();
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel(); if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel(); if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
@ -743,7 +741,6 @@ function adminFlashButtonBackground(btn, color) {
} }
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || ''; window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
loadAssistantImageModels(); loadAssistantImageModels();
loadAssistantPromptPoolStatus();
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8'); setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400'); setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior); setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
@ -813,91 +810,6 @@ function adminFlashButtonBackground(btn, color) {
return match ? match[1] : ''; return match ? match[1] : '';
} }
function loadAssistantPromptPoolStatus() {
var result = document.getElementById('assistant-prompt-pool-status');
if (result) result.textContent = 'Checking prompt pool...';
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
renderAssistantPromptPoolStatus(data.meta);
renderAssistantPromptPoolSnapshots(data.snapshots || []);
})
.catch(function(err) {
if (result) result.textContent = err.message;
});
}
function regenerateAssistantPromptPool() {
var result = document.getElementById('assistant-prompt-pool-status');
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
if (btn) btn.disabled = true;
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool regenerated', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
}).finally(function() {
if (btn) btn.disabled = false;
});
}
function renderAssistantPromptPoolStatus(meta) {
var result = document.getElementById('assistant-prompt-pool-status');
if (!result) return;
if (!meta) {
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
return;
}
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
}
function renderAssistantPromptPoolSnapshots(snapshots) {
var select = document.getElementById('assistant-prompt-pool-snapshots');
if (!select) return;
select.innerHTML = '';
if (!snapshots.length) {
var empty = document.createElement('option');
empty.value = '';
empty.textContent = 'No saved snapshots';
select.appendChild(empty);
return;
}
snapshots.forEach(function(s) {
var opt = document.createElement('option');
opt.value = s.id;
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
select.appendChild(opt);
});
}
function restoreAssistantPromptPool() {
var select = document.getElementById('assistant-prompt-pool-snapshots');
var id = select ? select.value : '';
var result = document.getElementById('assistant-prompt-pool-status');
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool restored', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function testAssistantImageModel() { function testAssistantImageModel() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1'; var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result'); var result = document.getElementById('assistant-image-test-result');

View file

@ -600,19 +600,9 @@ window.nativeStopRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {} try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
}; };
// Keep screen awake during recording. // Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap)
//
// Prefer the NativeRecording bridge (addJavascriptInterface, so it is present
// on the remote origin the launcher navigates to). The Capacitor KeepAwake
// plugin is kept as a fallback but is NOT installed in this project — relying
// on it alone meant this function silently did nothing and the screen slept
// mid-recording, killing the MediaRecorder.
window.nativeKeepAwake = function(on) { window.nativeKeepAwake = function(on) {
try { try {
if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') {
window.NativeRecording.keepAwake(!!on);
return;
}
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) { if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake(); if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
else window.Capacitor.Plugins.KeepAwake.allowSleep(); else window.Capacitor.Plugins.KeepAwake.allowSleep();

View file

@ -5,9 +5,9 @@ export function createAssistantImageStore() {
var generatedImageSeq = 0; var generatedImageSeq = 0;
var previewKeyHandler = null; var previewKeyHandler = null;
function renderGeneratedImage(src, alt, downloadUrl) { function renderGeneratedImage(src, alt) {
var id = 'img-' + (++generatedImageSeq); var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' }; generatedImages[id] = src;
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' + return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
'<div class="assistant-image-actions">' + '<div class="assistant-image-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' + '<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
@ -16,8 +16,7 @@ export function createAssistantImageStore() {
} }
function openImagePreview(id) { function openImagePreview(id) {
var item = generatedImages[id]; var src = generatedImages[id];
var src = item && (item.src || item);
if (!src) return; if (!src) return;
closeImagePreview(); closeImagePreview();
var modal = document.createElement('div'); var modal = document.createElement('div');
@ -42,24 +41,14 @@ export function createAssistantImageStore() {
} }
async function downloadImage(id) { async function downloadImage(id) {
var item = generatedImages[id]; var src = generatedImages[id];
var src = item && (item.src || item);
var downloadUrl = item && item.downloadUrl;
if (!src) return; if (!src) return;
try { try {
if (downloadUrl) {
await downloadFromServer(downloadUrl);
return;
}
if (await saveWithNativeShare(src)) return; if (await saveWithNativeShare(src)) return;
if (isNativeApp()) { if (isNativeApp()) {
if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error'); if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error');
return; return;
} }
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
await downloadWithBrowser(src); await downloadWithBrowser(src);
} catch (e) { } catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error'); if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error');
@ -134,7 +123,7 @@ async function saveWithCapacitorShare(src) {
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success'); if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true; return true;
} catch (e) { } catch (e) {
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error'); if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Opening browser download instead.', 'error');
return false; return false;
} }
} }
@ -147,14 +136,10 @@ async function shareWithWebFile(src) {
if (!navigator.canShare({ files: [file] })) return 'unavailable'; if (!navigator.canShare({ files: [file] })) return 'unavailable';
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' }); await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return 'shared'; return 'shared';
} catch (e) { return isShareCancel(e) ? 'unavailable' : 'unavailable'; } } catch (e) { return isShareCancel(e) ? 'cancelled' : 'unavailable'; }
} }
async function downloadWithBrowser(src) { async function downloadWithBrowser(src) {
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
var blob = await imageSourceToBlob(src); var blob = await imageSourceToBlob(src);
var objectUrl = URL.createObjectURL(blob); var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a'); var a = document.createElement('a');
@ -165,79 +150,15 @@ async function downloadWithBrowser(src) {
a.click(); a.click();
a.remove(); a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000); setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
async function downloadFromServer(url) {
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin' });
if (!response.ok) throw new Error('Image download failed');
var blob = await response.blob();
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
if (await saveBlobWithNativeImageBridge(blob, name)) return;
if (await saveBlobWithCapacitorShare(blob, name)) return;
if (await shareBlobWithWebFile(blob, name)) return;
if (isMobileBrowser()) { if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error'); setTimeout(function() {
return; var opened = window.open(objectUrl, '_blank', 'noopener');
if (opened && typeof window.showToast === 'function') {
window.showToast('If the download did not start, long-press the opened image and save it.', 'success');
} }
downloadBlobWithAnchor(blob, name); }, 300);
} }
function authHeadersForDownload() {
var headers = window.getAuthHeaders ? window.getAuthHeaders() : {};
var clean = {};
Object.keys(headers || {}).forEach(function(key) {
if (key.toLowerCase() !== 'content-type') clean[key] = headers[key];
});
return clean;
}
async function saveBlobWithNativeImageBridge(blob, name) {
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await blobToBase64(blob);
var result = String(window.NativeFiles.saveImage(name, base64) || '');
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
}
} catch (e) {}
return false;
}
async function saveBlobWithCapacitorShare(blob, name) {
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var base64 = await blobToBase64(blob);
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) { return false; }
}
async function shareBlobWithWebFile(blob, name) {
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return false;
try {
var file = new File([blob], name, { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return false;
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return true;
} catch (e) { return false; }
}
function downloadBlobWithAnchor(blob, name) {
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
a.download = name;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
} }
function isMobileBrowser() { function isMobileBrowser() {
@ -256,10 +177,6 @@ function isShareCancel(error) {
async function imageSourceToBase64(src) { async function imageSourceToBase64(src) {
if (/^data:image\//i.test(src)) return src.split(',')[1] || ''; if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src); var blob = await imageSourceToBlob(src);
return blobToBase64(blob);
}
async function blobToBase64(blob) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); }; reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); };

View file

@ -47,86 +47,50 @@ document.addEventListener('DOMContentLoaded', function() {
// 2FA still applies on top: biometric replaces the password step but // 2FA still applies on top: biometric replaces the password step but
// a 2FA-enabled account still gets the TOTP prompt afterwards. That // a 2FA-enabled account still gets the TOTP prompt afterwards. That
// is the intended defense-in-depth. // is the intended defense-in-depth.
// Two plugins cooperate here, because the one that does the biometric var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
// prompt does not store anything:
// - BiometricAuthNative (@aparajita/capacitor-biometric-auth) — presents
// the Face ID / fingerprint prompt. checkBiometry() + authenticate().
// - SecureStoragePlugin (capacitor-secure-storage-plugin), via the
// window.SecureStorage wrapper — holds the credentials in the iOS
// Keychain / Android EncryptedSharedPreferences.
//
// This previously called window.Capacitor.Plugins.NativeBiometric, the API
// of capacitor-native-biometric — a package that is not a dependency of this
// project. The plugin object was always undefined, so bioAvailable() always
// resolved {ok:false} and the biometric button was never revealed. The
// feature has been dead since it was written.
var BIO_CREDS_KEY = 'ped_bio_creds'; // SecureStorage key holding {username,password}
var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe
// BiometryType enum from the plugin (numeric) → human label.
var BIO_TYPE_NAMES = {
1: 'Touch ID',
2: 'Face ID',
3: 'fingerprint',
4: 'face recognition',
5: 'iris recognition'
};
function bioPlugin() { function bioPlugin() {
try { try {
if (!isNativeApp()) return null; if (!isNativeApp()) return null;
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.BiometricAuthNative; var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric;
return p || null; return p || null;
} catch (e) { return null; } } catch (e) { return null; }
} }
function bioAvailable() { function bioAvailable() {
var p = bioPlugin(); var p = bioPlugin();
if (!p) return Promise.resolve({ ok: false }); if (!p) return Promise.resolve({ ok: false });
return p.checkBiometry() return p.isAvailable()
.then(function (r) { .then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
return {
ok: !!(r && r.isAvailable),
type: r && r.biometryType,
typeName: (r && BIO_TYPE_NAMES[r.biometryType]) || 'biometric'
};
})
.catch(function () { return { ok: false }; }); .catch(function () { return { ok: false }; });
} }
function bioStored() { function bioStored() {
// Cheap check first — was biometric ever enrolled? If not, skip the // Cheap check first — was biometric ever enrolled? If not, skip the
// prompt path entirely so we don't rattle the user. // verifyIdentity prompt path entirely so we don't rattle the user.
try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; } try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; }
} }
function bioEnroll(email, password) { function bioEnroll(email, password) {
if (!bioPlugin()) return Promise.reject(new Error('Biometric plugin unavailable')); var p = bioPlugin();
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
return Promise.resolve( return p.setCredentials({ username: email, password: password, server: BIO_SERVER })
window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password })) .then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
).then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
} }
function bioRetrieve() { function bioRetrieve() {
var p = bioPlugin(); var p = bioPlugin();
if (!p) return Promise.reject(new Error('Biometric plugin unavailable')); if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); return p.verifyIdentity({
// authenticate() resolves on success and rejects on cancel/failure, so the
// credentials are only read after the OS has verified the user.
return p.authenticate({
reason: 'Sign in to PedScribe', reason: 'Sign in to PedScribe',
androidTitle: 'PedScribe', title: 'PedScribe',
androidSubtitle: 'Use biometric to sign in', subtitle: 'Use biometric to sign in',
cancelTitle: 'Use password', description: 'Confirm your identity to continue.'
allowDeviceCredential: false
}).then(function () { }).then(function () {
return window.SecureStorage.get(BIO_CREDS_KEY); return p.getCredentials({ server: BIO_SERVER });
}).then(function (raw) {
if (!raw) throw new Error('No stored credentials');
return JSON.parse(raw);
}); });
} }
function bioForget() { function bioForget() {
var p = bioPlugin();
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {} try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
if (!window.SecureStorage) return Promise.resolve(); if (!p) return Promise.resolve();
return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).catch(function () { /* fine if missing */ }); return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ });
} }
// Expose a small surface so settings/logout/etc can call into it. // Expose a small surface so settings/logout/etc can call into it.
window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget }; window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget };
@ -149,8 +113,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (!s.ok) return; if (!s.ok) return;
// Tweak the label to the actual biometry type when known. // Tweak the label to the actual biometry type when known.
var label = document.getElementById('bio-login-label'); var label = document.getElementById('bio-login-label');
if (label && s.typeName) { if (label && s.type) {
label.textContent = 'Sign in with ' + s.typeName; var typeMap = { 'FACE_ID': 'Sign in with Face ID', 'TOUCH_ID': 'Sign in with Touch ID', 'FACE_AUTHENTICATION': 'Sign in with face recognition', 'FINGERPRINT': 'Sign in with fingerprint' };
label.textContent = typeMap[s.type] || 'Sign in with biometric';
} }
btn.classList.remove('hidden'); btn.style.display = ''; btn.classList.remove('hidden'); btn.style.display = '';
if (div) { div.classList.remove('hidden'); div.style.display = ''; } if (div) { div.classList.remove('hidden'); div.style.display = ''; }
@ -249,71 +214,6 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
// ---- CLOUDFLARE TURNSTILE ----
// Gates registration and password reset. Login is deliberately NOT gated:
// it is already covered by a 10-per-15-min rate limit and a constant-time
// credential check, and the widget is unreliable inside the Capacitor
// WebView — which locked mobile users out of the app entirely.
//
// Tokens are captured from the render callback rather than read back out of
// the injected [name="cf-turnstile-response"] input. That lookup is easy to
// leave unscoped, which is exactly how the register form ended up
// submitting the login widget's token (single-use, 5-minute expiry).
//
// Rendering is explicit and deferred until the owning form is visible:
// both widgets live in forms that start at display:none, and Turnstile does
// not reliably complete a challenge inside a hidden container.
var turnstileWidgets = {
register: { el: 'turnstile-register', id: null, token: '', pending: false },
forgot: { el: 'turnstile-forgot', id: null, token: '', pending: false }
};
var turnstileReady = false;
// api.js?render=explicit invokes this once the Turnstile API is available.
window.onloadTurnstileCallback = function() {
turnstileReady = true;
Object.keys(turnstileWidgets).forEach(function(name) {
// Catch up on any form shown before the script finished loading.
if (turnstileWidgets[name].pending) renderTurnstile(name);
});
};
function renderTurnstile(name) {
var w = turnstileWidgets[name];
if (!w || w.id !== null) return; // already rendered
var el = document.getElementById(w.el);
if (!el) return;
if (!turnstileReady || !window.turnstile) { w.pending = true; return; }
w.pending = false;
w.id = window.turnstile.render(el, {
sitekey: el.getAttribute('data-sitekey'),
theme: 'light',
callback: function(token) { w.token = token; },
'expired-callback': function() { w.token = ''; },
'timeout-callback': function() { w.token = ''; },
// Without this a widget failure is silent and the user only ever sees
// the generic "complete the verification" toast with no way to tell
// whether the challenge failed, expired, or never loaded at all.
'error-callback': function(code) {
w.token = '';
console.error('[Auth] Turnstile error on ' + name + ' widget:', code);
showToast('Verification unavailable (' + (code || 'error') + '). Check your connection and try again.', 'error');
}
});
}
function turnstileToken(name) {
var w = turnstileWidgets[name];
return w ? w.token : '';
}
function resetTurnstile(name) {
var w = turnstileWidgets[name];
if (!w) return;
w.token = '';
if (w.id !== null && window.turnstile) window.turnstile.reset(w.id);
}
// ---- HELPER FUNCTIONS ---- // ---- HELPER FUNCTIONS ----
function showLoginForm() { function showLoginForm() {
@ -326,14 +226,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (loginForm) loginForm.style.display = 'none'; if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'block'; if (registerForm) registerForm.style.display = 'block';
if (forgotForm) forgotForm.style.display = 'none'; if (forgotForm) forgotForm.style.display = 'none';
renderTurnstile('register');
} }
function showForgotForm() { function showForgotForm() {
if (loginForm) loginForm.style.display = 'none'; if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'none'; if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'block'; if (forgotForm) forgotForm.style.display = 'block';
renderTurnstile('forgot');
} }
function enterApp(user, token) { function enterApp(user, token) {
@ -629,7 +527,7 @@ document.addEventListener('DOMContentLoaded', function() {
// ---- BIOMETRIC LOGIN BUTTON ---- // ---- BIOMETRIC LOGIN BUTTON ----
// Reads the email + password from the OS-secured keychain (gated behind // Reads the email + password from the OS-secured keychain (gated behind
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the // Face ID / Touch ID / fingerprint) and fills the login form. Submits the
// form so all the existing flow (2FA prompt, error handling, // form so all the existing flow (turnstile, 2FA prompt, error handling,
// session storage) runs unchanged. If biometric verification fails, the // session storage) runs unchanged. If biometric verification fails, the
// user just gets a toast and falls through to typing the password. // user just gets a toast and falls through to typing the password.
var bioBtn = document.getElementById('btn-bio-login'); var bioBtn = document.getElementById('btn-bio-login');
@ -646,7 +544,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (emailEl) emailEl.value = creds.username; if (emailEl) emailEl.value = creds.username;
if (pwEl) pwEl.value = creds.password; if (pwEl) pwEl.value = creds.password;
// Trigger the same submit path as the password form so all the // Trigger the same submit path as the password form so all the
// existing handling (2FA, session storage, etc.) runs unchanged. // existing handling (turnstile token, 2FA, session storage, etc.)
// runs unchanged. If turnstile hasn't auto-solved yet the form
// will toast "Please complete the verification" — same as a
// manual login attempt before turnstile resolves.
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit(); if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}) })
@ -676,9 +577,17 @@ document.addEventListener('DOMContentLoaded', function() {
return false; return false;
} }
// Cloudflare Turnstile
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
var loginToken = loginTurnstile ? loginTurnstile.value : '';
if (!loginToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Signing in...'); showLoading('Signing in...');
var body = { email: email, password: password }; var body = { email: email, password: password, turnstileToken: loginToken };
if (totpCode) body.totpCode = totpCode; if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', { fetch('/api/auth/login', {
@ -716,7 +625,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (isNativeApp() && !bioStored()) { if (isNativeApp() && !bioStored()) {
bioAvailable().then(function (s) { bioAvailable().then(function (s) {
if (!s.ok) return; if (!s.ok) return;
var typeName = s.typeName || 'biometric'; var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric';
if (typeof showConfirm === 'function') { if (typeof showConfirm === 'function') {
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () { showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
bioEnroll(email, password) bioEnroll(email, password)
@ -728,12 +637,14 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} else { } else {
showToast(data.error || 'Login failed', 'error'); showToast(data.error || 'Login failed', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
} }
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
console.error('[Auth] Login error:', err); console.error('[Auth] Login error:', err);
showToast('Connection error', 'error'); showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
}); });
return false; return false;
@ -792,8 +703,9 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Cloudflare Turnstile verification // Cloudflare Turnstile verification
var regToken = turnstileToken('register'); var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
if (!regToken) { var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
if (!turnstileToken) {
showToast('Please complete the verification challenge', 'error'); showToast('Please complete the verification challenge', 'error');
return false; return false;
} }
@ -803,7 +715,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch('/api/auth/register', { fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken }) body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@ -817,20 +729,17 @@ document.addEventListener('DOMContentLoaded', function() {
showToast(data.message || 'Account created!', 'success'); showToast(data.message || 'Account created!', 'success');
} else if (data.success && data.needsVerification) { } else if (data.success && data.needsVerification) {
showToast(data.message || 'Check email to verify', 'success'); showToast(data.message || 'Check email to verify', 'success');
// The token was just consumed server-side — clear it so coming back
// to this form doesn't resubmit a spent one.
resetTurnstile('register');
showLoginForm(); showLoginForm();
} else { } else {
showToast(data.error || 'Registration failed', 'error'); showToast(data.error || 'Registration failed', 'error');
resetTurnstile('register'); if (window.turnstile) turnstile.reset();
} }
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
console.error('[Auth] Register error:', err); console.error('[Auth] Register error:', err);
showToast('Connection error', 'error'); showToast('Connection error', 'error');
resetTurnstile('register'); if (window.turnstile) turnstile.reset();
}); });
return false; return false;
@ -847,7 +756,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (!email) { showToast('Enter email', 'error'); return false; } if (!email) { showToast('Enter email', 'error'); return false; }
// Cloudflare Turnstile // Cloudflare Turnstile
var forgotToken = turnstileToken('forgot'); var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
if (!forgotToken) { if (!forgotToken) {
showToast('Please complete the verification', 'error'); showToast('Please complete the verification', 'error');
return false; return false;
@ -864,13 +774,12 @@ document.addEventListener('DOMContentLoaded', function() {
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
showToast(data.message || 'Check your email', 'success'); showToast(data.message || 'Check your email', 'success');
resetTurnstile('forgot');
showLoginForm(); showLoginForm();
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
showToast('Error', 'error'); showToast('Error', 'error');
resetTurnstile('forgot'); if (window.turnstile) turnstile.reset('#turnstile-forgot');
}); });
return false; return false;

View file

@ -492,11 +492,10 @@ import {
if (!src) throw new Error('No image returned'); if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src; lastGeneratedImageSrc = src;
exporter.invalidate(); exporter.invalidate();
var downloadUrl = data.downloadUrl || ''; if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
if (fromChat) { if (fromChat) {
setBusy(false, 'Ready'); setBusy(false, 'Ready');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl); var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
replaceLoadingMessage(loading, html, [], [], true); replaceLoadingMessage(loading, html, [], [], true);
} }
}) })

View file

@ -5,6 +5,7 @@
# Usage: # Usage:
# scripts/release.sh 6.1.1 # bump to 6.1.1 # scripts/release.sh 6.1.1 # bump to 6.1.1
# scripts/release.sh 6.1.1 --push # also git push + tag push # scripts/release.sh 6.1.1 --push # also git push + tag push
# scripts/release.sh 6.2.0 --push --gh # also create GitHub release
# #
# What it does: # What it does:
# 1. Updates version in root package.json # 1. Updates version in root package.json
@ -12,32 +13,31 @@
# 3. Updates versionName + bumps versionCode in Android build.gradle # 3. Updates versionName + bumps versionCode in Android build.gradle
# 4. Commits the version bump # 4. Commits the version bump
# 5. (optional) git push + push the new tag # 5. (optional) git push + push the new tag
# 6. (optional) create a GitHub release via gh CLI
# #
# It does NOT: # It does NOT:
# - Build the Docker image (run `docker compose build`/`up -d` yourself # - Build the Docker image (run `docker compose build`/`up -d` yourself
# or wire it to a deploy script / CI hook) # or wire it to a deploy script / CI hook)
# - Build the Android APK itself. Forgejo CI does that # - Build the Android APK (run `cd mobile/android && ./gradlew ...`
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed # yourself). This script just marks the version so the build tags
# APK as a 30-day workflow artifact, and a v* tag push additionally # correctly.
# attaches it to a Forgejo release, which is what Obtainium tracks for
# updates. So --push is all you need to ship a build.
# - Publish the release itself. CI does that on the tag push; there is no
# manual publish step to run.
# ============================================================ # ============================================================
set -euo pipefail set -euo pipefail
VERSION="${1:-}" VERSION="${1:-}"
PUSH=false PUSH=false
DO_RELEASE=false
for arg in "${@:2}"; do for arg in "${@:2}"; do
case "$arg" in case "$arg" in
--push) PUSH=true ;; --push) PUSH=true ;;
--gh) DO_RELEASE=true ;;
esac esac
done done
if [[ -z "$VERSION" ]]; then if [[ -z "$VERSION" ]]; then
echo "usage: $0 <version> [--push]" echo "usage: $0 <version> [--push] [--gh]"
echo " example: $0 6.1.1 --push" echo " example: $0 6.1.1 --push --gh"
exit 1 exit 1
fi fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
@ -90,25 +90,30 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
echo " tagged v${VERSION}" echo " tagged v${VERSION}"
if $PUSH; then if $PUSH; then
# This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back git push origin HEAD
# to origin, otherwise use the only remote there is — so this keeps working git push origin "v${VERSION}"
# if the remote is ever renamed. Pushing the tag is what makes CI attach the echo " pushed to origin"
# signed APK to a Forgejo release for Obtainium; the branch push alone only fi
# builds it as a 30-day workflow artifact.
REMOTE="" if $DO_RELEASE; then
for candidate in forgejo origin; do if ! command -v gh >/dev/null; then
if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2
done else
if [[ -z "$REMOTE" ]]; then APK="mobile/android/app/build/outputs/apk/release/app-release.apk"
REMOTE=$(git remote | head -1) if [[ -f "$APK" ]]; then
gh release create "v${VERSION}" "$APK" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release with APK"
else
gh release create "v${VERSION}" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release (no APK attached — run gradle first then gh release upload)"
fi fi
if [[ -z "$REMOTE" ]]; then
echo "ERROR: no git remote configured — cannot push. Commit and tag are still local." >&2
exit 1
fi fi
git push "$REMOTE" HEAD
git push "$REMOTE" "v${VERSION}"
echo " pushed to $REMOTE"
fi fi
echo "==> Done. v$VERSION." echo "==> Done. v$VERSION."

View file

@ -376,26 +376,6 @@ async function initDatabase() {
console.warn('⚠️ Could not create milestones table:', e.message); console.warn('⚠️ Could not create milestones table:', e.message);
} }
// Durable history for generated clinical assistant starter prompts.
// Redis serves the active pool; Postgres keeps snapshots for rollback.
try { await client.query(`
CREATE TABLE IF NOT EXISTS clinical_prompt_pool_snapshots (
id SERIAL PRIMARY KEY,
generated_at TIMESTAMPTZ DEFAULT NOW(),
target INTEGER DEFAULT 0,
count INTEGER DEFAULT 0,
payload JSONB NOT NULL,
restored_from INTEGER REFERENCES clinical_prompt_pool_snapshots(id) ON DELETE SET NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_prompt_pool_snapshots_created ON clinical_prompt_pool_snapshots(created_at DESC);
`);
console.log('✅ clinical_prompt_pool_snapshots: table ready');
} catch(e) {
console.warn('⚠️ Could not create clinical prompt pool snapshots table:', e.message);
}
// Create IVFFLAT index for fast similarity search (after data is populated) // Create IVFFLAT index for fast similarity search (after data is populated)
try { try {
var indexExists = await client.query( var indexExists = await client.query(

View file

@ -9,7 +9,7 @@ var { authMiddleware, adminMiddleware } = require('../middleware/auth');
var PROMPTS = require('../utils/prompts'); var PROMPTS = require('../utils/prompts');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm'); var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings'); var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
@ -91,55 +91,6 @@ router.get('/config', async function(req, res) {
} catch (e) { res.status(500).json({ error: 'Request failed' }); } } catch (e) { res.status(500).json({ error: 'Request failed' }); }
}); });
// ── Clinical assistant starter prompt pool ─────────────────────────────────
router.get('/clinical-assistant/prompt-pool', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool status failed' });
}
});
router.get('/clinical-assistant/prompt-pool/snapshots', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(req.query.limit || 20);
res.json({ success: true, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool snapshot listing failed' });
}
});
router.post('/clinical-assistant/prompt-pool/regenerate', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var examples = await clinicalAssistant.refreshPromptPool(true, req.user && req.user.id);
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: examples.length, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool regeneration failed' });
}
});
router.post('/clinical-assistant/prompt-pool/restore', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var id = Number(req.body && req.body.id);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Snapshot id is required' });
var payload = await clinicalAssistant.restorePromptPoolSnapshot(id, req.user && req.user.id);
if (!payload) return res.status(404).json({ error: 'Snapshot not found' });
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: Array.isArray(payload.examples) ? payload.examples.length : 0, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool restore failed' });
}
});
// ── POST send test email ─────────────────────────────────────────────────── // ── POST send test email ───────────────────────────────────────────────────
router.post('/config/test-email', async function(req, res) { router.post('/config/test-email', async function(req, res) {
try { try {
@ -550,17 +501,12 @@ router.get('/config/tts', async function(req, res) {
var dbModel = await db.getSetting('tts.model') || ''; var dbModel = await db.getSetting('tts.model') || '';
var envVoice = process.env.LITELLM_TTS_VOICE || ''; var envVoice = process.env.LITELLM_TTS_VOICE || '';
var envModel = process.env.LITELLM_TTS_MODEL || ''; var envModel = process.env.LITELLM_TTS_MODEL || '';
var currentModel = dbModel || envModel;
var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice });
var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(currentModel, voice);
}) || '';
res.json({ res.json({
success: true, success: true,
provider: activeProvider, provider: activeProvider,
envProvider: envProvider, envProvider: envProvider,
currentVoice: currentVoice, currentVoice: dbVoice || envVoice,
currentModel: currentModel, currentModel: dbModel || envModel,
dbVoice: dbVoice, dbVoice: dbVoice,
dbModel: dbModel, dbModel: dbModel,
envVoice: envVoice, envVoice: envVoice,
@ -568,9 +514,7 @@ router.get('/config/tts', async function(req, res) {
configured: { configured: {
litellm: !!process.env.LITELLM_API_BASE litellm: !!process.env.LITELLM_API_BASE
}, },
voices: { voices: getTTSVoiceLists()
litellm: voices
}
}); });
} catch (e) { res.status(500).json({ error: 'Request failed' }); } } catch (e) { res.status(500).json({ error: 'Request failed' }); }
}); });
@ -623,15 +567,11 @@ router.post('/config/tts/test', async function(req, res) {
var adminModel = await db.getSetting('tts.model') || ''; var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || ''; var adminVoice = await db.getSetting('tts.voice') || '';
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || ''; var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice }); var usedVoice = voice || adminVoice || process.env.LITELLM_TTS_VOICE || '';
var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) {
return isLiteLLMTTSVoiceCompatible(ttsModel, candidate);
}) || '';
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' }); if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await axios.post(gatewayUrl('/audio/speech'), var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
payload, { model: ttsModel, voice: usedVoice, input: text },
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 } { headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
); );
var buffer = Buffer.from(ttsResp.data); var buffer = Buffer.from(ttsResp.data);

View file

@ -299,15 +299,19 @@ router.post('/resend-verification', async (req, res) => {
// ============================================================ // ============================================================
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
var { email, password, totpCode } = req.body; var { email, password, totpCode, turnstileToken } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
// No Turnstile on login. The widget could not reliably complete a // Cloudflare Turnstile verification
// challenge inside the Capacitor WebView, which locked mobile users out. if (process.env.TURNSTILE_SECRET_KEY) {
// Brute-force cover here comes from the 10-per-15-min per-IP rate limit if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
// (server.js), the constant-time bcrypt comparison below, and TOTP 2FA. var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
// Registration and password reset — the endpoints that actually attract method: 'POST', headers: { 'Content-Type': 'application/json' },
// bots — are still gated. body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
// Enumeration-resistant: always run bcrypt to keep timing constant, and return // Enumeration-resistant: always run bcrypt to keep timing constant, and return

View file

@ -55,9 +55,7 @@ var promptPool = createClinicalPromptPool({
semanticSearch: semanticSearch, semanticSearch: semanticSearch,
dedupeSources: dedupeSources, dedupeSources: dedupeSources,
normalizeMcpSearchResponse: normalizeMcpSearchResponse, normalizeMcpSearchResponse: normalizeMcpSearchResponse,
getIndexedTopicExamples: getIndexedTopicExamples, getIndexedTopicExamples: getIndexedTopicExamples
loadStoredPromptPool: loadLatestPromptPoolSnapshot,
savePromptPool: savePromptPoolSnapshot
}); });
function positiveInt(value, fallback) { function positiveInt(value, fallback) {
@ -325,7 +323,6 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
imageUrl: job.imageUrl || null, imageUrl: job.imageUrl || null,
url: job.url || null, url: job.url || null,
base64: job.base64 || null, base64: job.base64 || null,
downloadUrl: job.status === 'done' && job.base64 ? '/api/clinical-assistant/image/jobs/' + encodeURIComponent(job.id) + '/download' : null,
error: job.error || null error: job.error || null
}); });
} catch (e) { } catch (e) {
@ -333,22 +330,6 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
} }
}); });
router.get('/clinical-assistant/image/jobs/:id/download', async function(req, res) {
try {
var job = await getImageJob(req.params.id);
if (!job || String(job.userId) !== String(req.user.id) || job.status !== 'done' || !job.base64) {
return res.status(404).json({ error: 'Image job not found' });
}
var buffer = Buffer.from(String(job.base64), 'base64');
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Disposition', 'attachment; filename="clinical-visual.png"');
res.setHeader('Cache-Control', 'private, max-age=900');
res.send(buffer);
} catch (e) {
res.status(500).json({ error: 'Image download failed' });
}
});
async function prepareAssistantChat(body) { async function prepareAssistantChat(body) {
body = body || {}; body = body || {};
var message = String(body.message || '').trim(); var message = String(body.message || '').trim();
@ -587,49 +568,6 @@ async function getAvailableExamples() {
return promptPool.getAvailableExamples(); return promptPool.getAvailableExamples();
} }
router.refreshPromptPool = function(force, userId) {
return promptPool.refreshIfNeeded(force !== false, { userId: userId || null });
};
router.getPromptPoolMeta = function() {
return promptPool.getMeta();
};
router.getPromptPoolSnapshots = getPromptPoolSnapshots;
router.restorePromptPoolSnapshot = restorePromptPoolSnapshot;
async function savePromptPoolSnapshot(payload, context) {
context = context || {};
var result = await db.run(
'INSERT INTO clinical_prompt_pool_snapshots (generated_at, target, count, payload, restored_from, created_by) VALUES (to_timestamp($1 / 1000.0), $2, $3, $4::jsonb, $5, $6) RETURNING id',
[payload.generatedAt || Date.now(), payload.target || 0, Array.isArray(payload.examples) ? payload.examples.length : 0, JSON.stringify(payload), context.restoredFrom || null, context.userId || null]
);
return result.lastInsertRowid;
}
async function loadLatestPromptPoolSnapshot() {
var row = await db.get('SELECT payload FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT 1', []);
if (!row || !row.payload) return null;
return typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
}
async function getPromptPoolSnapshots(limit) {
limit = clampInt(limit || 20, 1, 50, 20);
return db.all(
'SELECT id, generated_at, target, count, restored_from, created_at FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT $1',
[limit]
);
}
async function restorePromptPoolSnapshot(id, userId) {
var row = await db.get('SELECT id, payload FROM clinical_prompt_pool_snapshots WHERE id = $1', [id]);
if (!row || !row.payload) return null;
var original = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
var payload = Object.assign({}, original, { generatedAt: Date.now(), restoredFrom: Number(row.id) });
await promptPool.writePromptPool(payload, { userId: userId || null, restoredFrom: Number(row.id) });
return payload;
}
async function getIndexedTopicExamples() { async function getIndexedTopicExamples() {
var response = await indexedTopicSuggestions(12); var response = await indexedTopicSuggestions(12);
var data = response && (response.structuredContent || response.data || response); var data = response && (response.structuredContent || response.data || response);
@ -652,18 +590,7 @@ async function getIndexedTopicExamples() {
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '', sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
category: item.category || '' category: item.category || ''
}; };
}).filter(isUsefulIndexedTopicExample); }).filter(function(item) { return item.prompt; });
}
function isUsefulIndexedTopicExample(item) {
var label = String(item && item.label || '');
var prompt = String(item && item.prompt || '');
if (!prompt || prompt.indexOf('?') === -1) return false;
var haystack = (label + ' ' + prompt + ' ' + String(item.sourceTitle || '')).toLowerCase();
if (/\b(start|end) of picture text\b/.test(haystack)) return false;
if (/\b(comparative|cross[-\s]?sectional|retrospective|prospective) study\b/.test(label.toLowerCase())) return false;
if (/\b(prevalence|correlation|association) of\b/.test(label.toLowerCase())) return false;
return true;
} }
async function generateImage(prompt, model) { async function generateImage(prompt, model) {

View file

@ -3,7 +3,7 @@ const router = express.Router();
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm'); var { getLiteLLMHeaders } = require('../utils/litellm');
// TTS is intentionally routed only through LiteLLM. Provider-specific voice // TTS is intentionally routed only through LiteLLM. Provider-specific voice
@ -22,22 +22,19 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
var userVoice = userPrefs?.tts_voice; var userVoice = userPrefs?.tts_voice;
var adminVoice = await db.getSetting('tts.voice') || ''; var adminVoice = await db.getSetting('tts.voice') || '';
var adminModel = await db.getSetting('tts.model') || ''; var adminModel = await db.getSetting('tts.model') || '';
var resolvedVoice = userVoice || adminVoice || '';
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) { if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' }); return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
} }
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || ''; var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice }); var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || '';
var ttsVoice = [userVoice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(ttsModel, voice);
}) || '';
if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' }); if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' });
var payload = Object.assign({ model: ttsModel, input: text, voice: ttsVoice }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await fetch(gatewayUrl('/audio/speech'), { var ttsResp = await fetch(gatewayUrl('/audio/speech'), {
method: 'POST', method: 'POST',
headers: getLiteLLMHeaders('application/json'), headers: getLiteLLMHeaders('application/json'),
body: JSON.stringify(payload) body: JSON.stringify({ model: ttsModel, input: text, voice: ttsVoice })
}); });
if (!ttsResp.ok) { if (!ttsResp.ok) {
var errBody = await ttsResp.text(); var errBody = await ttsResp.text();

View file

@ -7,7 +7,7 @@ var router = express.Router();
var db = require('../db/database'); var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth'); var { authMiddleware } = require('../middleware/auth');
var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
router.use(authMiddleware); router.use(authMiddleware);
@ -49,18 +49,14 @@ router.get('/preferences/options', async function(req, res) {
var provider = getSTTProvider(); var provider = getSTTProvider();
var ttsProvider = getTTSProvider(); var ttsProvider = getTTSProvider();
var dbModel = await db.getSetting('tts.model') || '';
var dbVoice = await db.getSetting('tts.voice') || '';
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; }); var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; });
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; }); var ttsVoices = getTTSVoiceLists().litellm.map(function(voice) { return { value: voice, label: voice }; });
res.json({ res.json({
success: true, success: true,
sttProvider: provider || 'none', sttProvider: provider || 'none',
sttModels: sttModels, sttModels: sttModels,
ttsProvider: ttsProvider || 'browser', ttsProvider: ttsProvider || 'browser',
ttsModel: ttsModel || null,
ttsVoices: ttsVoices ttsVoices: ttsVoices
}); });

View file

@ -243,23 +243,22 @@ function parseJsonObject(text) {
function createClinicalPromptPool(opts) { function createClinicalPromptPool(opts) {
opts = opts || {}; opts = opts || {};
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000); var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
var refreshMs = nonNegativeInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 * 24 * 60 * 60 * 1000); var refreshMs = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
var ttlSeconds = refreshMs > 0 ? Math.max(3600, Math.ceil(refreshMs / 1000) * 2) : 0; var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000); var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000);
var redisBaseKey = String(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v2').replace(/:all$/, ''); var redisBaseKey = String(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v2').replace(/:all$/, '');
var redisAllKey = redisBaseKey + ':all'; var redisAllKey = redisBaseKey + ':all';
var redisMetaKey = redisBaseKey + ':meta'; var redisMetaKey = redisBaseKey + ':meta';
var redisLastGoodKey = redisBaseKey + ':last-good';
var memoryCache = { expiresAt: 0, examples: [] }; var memoryCache = { expiresAt: 0, examples: [] };
var refreshPromise = null; var refreshPromise = null;
async function getAvailableExamples() { async function getAvailableExamples() {
var now = Date.now(); var now = Date.now();
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples; if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
var cached = await readPromptPool(); var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) { if (cached && Array.isArray(cached.examples) && cached.examples.length) {
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples }; memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
if (refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs)) refreshIfNeeded(false).catch(function() {}); if (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
return cached.examples; return cached.examples;
} }
@ -271,11 +270,10 @@ function createClinicalPromptPool(opts) {
return examples; return examples;
} }
async function refreshIfNeeded(force, context) { async function refreshIfNeeded(force) {
context = context || {};
var now = Date.now(); var now = Date.now();
var cached = await readPromptPool(); var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (cached.generatedAt && now - cached.generatedAt < refreshMs))) { if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
return cached.examples; return cached.examples;
} }
if (refreshPromise) return refreshPromise; if (refreshPromise) return refreshPromise;
@ -284,30 +282,15 @@ function createClinicalPromptPool(opts) {
if (!examples.length) return []; if (!examples.length) return [];
var payload = { generatedAt: Date.now(), target: target, examples: examples }; var payload = { generatedAt: Date.now(), target: target, examples: examples };
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples }; memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
return writePromptPool(payload, context).then(function() { return examples; }); return writePromptPool(payload).then(function() { return examples; });
}).catch(function(e) { }).catch(function(e) {
refreshPromise = null; refreshPromise = null;
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message); console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
return cached && Array.isArray(cached.examples) ? cached.examples : []; return [];
}); });
return refreshPromise; return refreshPromise;
} }
async function readPromptPool() {
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) return cached;
var lastGood = await opts.redisCache.getJson(redisLastGoodKey).catch(function() { return null; });
if (lastGood && Array.isArray(lastGood.examples) && lastGood.examples.length) return lastGood;
if (typeof opts.loadStoredPromptPool === 'function') {
var stored = await opts.loadStoredPromptPool().catch(function() { return null; });
if (stored && Array.isArray(stored.examples) && stored.examples.length) {
await writePromptPool(stored, { skipStore: true }).catch(function() {});
return stored;
}
}
return null;
}
async function buildCorpusPromptPool() { async function buildCorpusPromptPool() {
var taxonomy = taxonomyWithQuotas(target); var taxonomy = taxonomyWithQuotas(target);
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', ''); var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
@ -339,7 +322,11 @@ function createClinicalPromptPool(opts) {
} }
generated = normalizeGeneratedExamples(generated.concat(categoryExamples)); generated = normalizeGeneratedExamples(generated.concat(categoryExamples));
} }
if (generated.length < 8) return []; generated = normalizeGeneratedExamples(generated.concat(taxonomyFallbackExamples(taxonomy, generated.length)));
if (generated.length < target) {
generated = normalizeGeneratedExamples(generated.concat(taxonomySupplementExamples(taxonomy, target - generated.length)));
}
if (generated.length < 8) return fallbackIndexedExamples();
return generated.slice(0, target); return generated.slice(0, target);
} }
@ -384,10 +371,6 @@ function createClinicalPromptPool(opts) {
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : []; return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
} }
async function getMeta() {
return await opts.redisCache.getJson(redisMetaKey).catch(function() { return null; });
}
function normalizeGeneratedExamples(items, defaultTaxonomy) { function normalizeGeneratedExamples(items, defaultTaxonomy) {
var seen = new Set(); var seen = new Set();
var out = []; var out = [];
@ -410,12 +393,10 @@ function createClinicalPromptPool(opts) {
return out.slice(0, target); return out.slice(0, target);
} }
async function writePromptPool(payload, context) { async function writePromptPool(payload) {
context = context || {};
var counts = buildPoolCounts(payload.examples); var counts = buildPoolCounts(payload.examples);
var writes = [ var writes = [
opts.redisCache.setJson(redisAllKey, payload, ttlSeconds), opts.redisCache.setJson(redisAllKey, payload, ttlSeconds),
opts.redisCache.setJson(redisLastGoodKey, payload, 0),
opts.redisCache.setJson(redisMetaKey, { opts.redisCache.setJson(redisMetaKey, {
generatedAt: payload.generatedAt, generatedAt: payload.generatedAt,
target: target, target: target,
@ -447,11 +428,6 @@ function createClinicalPromptPool(opts) {
}, ttlSeconds)); }, ttlSeconds));
}); });
await Promise.all(writes); await Promise.all(writes);
if (!context.skipStore && typeof opts.savePromptPool === 'function') {
await opts.savePromptPool(payload, context).catch(function(e) {
console.warn('[clinical-assistant] prompt pool snapshot save failed:', e.message);
});
}
} }
function buildPoolCounts(examples) { function buildPoolCounts(examples) {
@ -499,6 +475,73 @@ function createClinicalPromptPool(opts) {
}); });
} }
function taxonomyFallbackExamples(taxonomy, existingCount) {
var templates = [
'How should I evaluate a {age} with {seed}, focusing on {focus}?',
'What red flags change disposition for a {age} with {seed} and {focus}?',
'What pediatric differential fits {seed} in a {age} with {focus}?',
'What initial management is recommended for a {age} with {seed} and {focus}?',
'When should a {age} with {seed} be admitted because of {focus}?',
'What return precautions fit {seed} in a {age} with {focus}?'
];
var focusAreas = [
'severity assessment',
'diagnostic workup',
'age-specific risks',
'medication dosing',
'fluid management',
'family safety netting',
'follow-up timing',
'comorbid conditions',
'initial stabilization',
'consultation thresholds'
];
var examples = [];
taxonomy.forEach(function(item) {
var needed = Math.max(0, item.quota - Math.floor(existingCount / taxonomy.length));
var itemExamples = [];
var seen = new Set();
for (var i = 0; i < needed * 3 && itemExamples.length < needed; i++) {
var seed = item.seeds[i % item.seeds.length];
var seedPhrase = seed.split(/\s+/).slice(0, 5).join(' ');
var ageBand = item.ageBands[i % item.ageBands.length];
var template = templates[i % templates.length];
var focus = focusAreas[Math.floor(i / (templates.length * item.seeds.length)) % focusAreas.length];
var example = {
label: cleanExampleLabel(seedPhrase.split(/\s+/).slice(0, 4).join(' ')),
prompt: template.replace('{age}', ageBand.replace('_', ' ') + ' patient').replace('{seed}', seedPhrase).replace('{focus}', focus),
category: item.category,
age_band: ageBand,
intent: ['diagnosis', 'red_flags', 'differential', 'management', 'admission', 'counseling'][i % 6]
};
var key = example.prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key)) continue;
seen.add(key);
itemExamples.push(example);
}
examples = examples.concat(itemExamples);
});
return examples;
}
function taxonomySupplementExamples(taxonomy, needed) {
var examples = [];
for (var i = 0; i < needed * 2 && examples.length < needed; i++) {
var item = taxonomy[i % taxonomy.length];
var seedPhrase = item.seeds[i % item.seeds.length].split(/\s+/).slice(0, 5).join(' ');
var ageBand = item.ageBands[i % item.ageBands.length];
var categoryLabel = item.category.replace(/_/g, ' ');
examples.push({
label: cleanExampleLabel(categoryLabel + ' review'),
prompt: 'For pediatric ' + categoryLabel + ' cases, how should I handle a ' + ageBand.replace('_', ' ') + ' patient with ' + seedPhrase + '?',
category: item.category,
age_band: ageBand,
intent: ['diagnosis', 'management', 'red_flags', 'differential', 'counseling', 'dosing', 'admission', 'review'][i % 8]
});
}
return examples;
}
function sanitizeCategory(category, defaultTaxonomy) { function sanitizeCategory(category, defaultTaxonomy) {
category = String(category || '').trim().toLowerCase().replace(/[\s/-]+/g, '_'); category = String(category || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
category = CATEGORY_ALIASES[category] || CATEGORY_ALIASES[category.replace(/_/g, ' ')] || category; category = CATEGORY_ALIASES[category] || CATEGORY_ALIASES[category.replace(/_/g, ' ')] || category;
@ -571,16 +614,8 @@ function createClinicalPromptPool(opts) {
return { return {
getAvailableExamples: getAvailableExamples, getAvailableExamples: getAvailableExamples,
refreshIfNeeded: refreshIfNeeded, refreshIfNeeded: refreshIfNeeded
getMeta: getMeta,
writePromptPool: writePromptPool
}; };
} }
function nonNegativeInt(value, fallback) {
if (value == null || value === '') return fallback;
var n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
module.exports = { createClinicalPromptPool }; module.exports = { createClinicalPromptPool };

View file

@ -34,11 +34,7 @@ async function getJson(key) {
async function setJson(key, value, ttlSeconds) { async function setJson(key, value, ttlSeconds) {
var redis = await getRedis(); var redis = await getRedis();
if (!redis) return false; if (!redis) return false;
if (ttlSeconds && Number(ttlSeconds) > 0) { await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
await redis.set(key, JSON.stringify(value), { EX: Math.floor(Number(ttlSeconds)) });
} else {
await redis.set(key, JSON.stringify(value));
}
return true; return true;
} }

View file

@ -7,20 +7,6 @@ function parseList(value) {
.filter(Boolean); .filter(Boolean);
} }
var KITTEN_TTS_VOICES = ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo'];
var SUPERTONIC_TTS_VOICES = ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5'];
var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy'];
var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha'];
function uniqueList(values) {
var seen = new Set();
return (values || []).filter(function(value) {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function getTTSProvider() { function getTTSProvider() {
var env = process.env.TTS_PROVIDER; var env = process.env.TTS_PROVIDER;
if (env === 'litellm') return 'litellm'; if (env === 'litellm') return 'litellm';
@ -34,58 +20,10 @@ function getTTSEnvProvider() {
function getTTSVoiceLists() { function getTTSVoiceLists() {
return { return {
litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_VOICES)) litellm: parseList(process.env.LITELLM_TTS_VOICES)
}; };
} }
function getLiteLLMTTSModelFamily(model) {
var id = String(model || '').toLowerCase();
if (id === 'local-kitten-tts') return 'kitten';
if (id === 'local-supertonic-tts') return 'supertonic';
if (id === 'local-kokoro-tts') return 'kokoro';
if (id === 'groq-orpheus-english' || id === 'canopylabs/orpheus-v1-english') return 'groq-orpheus-english';
if (id === 'groq-orpheus-arabic-saudi' || id === 'canopylabs/orpheus-arabic-saudi') return 'groq-orpheus-arabic';
return 'unknown';
}
function getLiteLLMTTSRequestOptions(model) {
var family = getLiteLLMTTSModelFamily(model);
if (family === 'groq-orpheus-english' || family === 'groq-orpheus-arabic') {
return { response_format: 'wav' };
}
return {};
}
function isLiteLLMTTSVoiceCompatible(model, voice) {
if (!voice) return true;
var family = getLiteLLMTTSModelFamily(model);
if (family === 'kitten') return KITTEN_TTS_VOICES.indexOf(voice) !== -1;
if (family === 'supertonic') return SUPERTONIC_TTS_VOICES.indexOf(voice) !== -1;
if (family === 'groq-orpheus-english') return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
if (family === 'groq-orpheus-arabic') return GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
if (family === 'kokoro') {
return KITTEN_TTS_VOICES.indexOf(voice) === -1 && SUPERTONIC_TTS_VOICES.indexOf(voice) === -1 &&
GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 && GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1;
}
return true;
}
function getLiteLLMTTSVoicesForModel(model, opts) {
opts = opts || {};
var family = getLiteLLMTTSModelFamily(model);
var voices = [];
if (family === 'kitten') voices = KITTEN_TTS_VOICES.slice();
else if (family === 'supertonic') voices = SUPERTONIC_TTS_VOICES.slice();
else if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice();
else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice();
else voices = parseList(process.env.LITELLM_TTS_VOICES);
[opts.currentVoice, process.env.LITELLM_TTS_VOICE].forEach(function(voice) {
if (isLiteLLMTTSVoiceCompatible(model, voice)) voices.push(voice);
});
return uniqueList(voices.filter(function(voice) { return isLiteLLMTTSVoiceCompatible(model, voice); }));
}
function isLiteLLMTTSModel(model) { function isLiteLLMTTSModel(model) {
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : ''; var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
return mode === 'audio_speech'; return mode === 'audio_speech';
@ -118,26 +56,6 @@ function getLiteLLMTTSDiscoveryItems(models, opts) {
getTTSVoiceLists().litellm.forEach(function(voice) { getTTSVoiceLists().litellm.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' }); pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' });
}); });
if (getLiteLLMTTSModels(models).indexOf('local-kitten-tts') !== -1 || opts.currentModel === 'local-kitten-tts') {
KITTEN_TTS_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Kitten ' + voice, source: 'local-kitten-tts', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('local-supertonic-tts') !== -1 || opts.currentModel === 'local-supertonic-tts') {
SUPERTONIC_TTS_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Supertonic ' + voice, source: 'local-supertonic-tts', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-english') !== -1 || opts.currentModel === 'groq-orpheus-english') {
GROQ_ORPHEUS_ENGLISH_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus ' + voice, source: 'groq-orpheus-english', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-arabic-saudi') !== -1 || opts.currentModel === 'groq-orpheus-arabic-saudi') {
GROQ_ORPHEUS_ARABIC_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus Arabic ' + voice, source: 'groq-orpheus-arabic-saudi', kind: 'voice' });
});
}
return items; return items;
} }
@ -146,10 +64,7 @@ module.exports = {
getLiteLLMTTSDiscoveryItems, getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders, getLiteLLMHeaders,
getLiteLLMTTSModels, getLiteLLMTTSModels,
getLiteLLMTTSRequestOptions,
getLiteLLMTTSVoicesForModel,
getTTSProvider, getTTSProvider,
getTTSVoiceLists, getTTSVoiceLists,
isLiteLLMTTSVoiceCompatible,
isLiteLLMTTSModel isLiteLLMTTSModel
}; };

View file

@ -14,35 +14,17 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
const pool = read('src/utils/clinicalPromptPool.js'); const pool = read('src/utils/clinicalPromptPool.js');
assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/); assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/);
assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/); assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/);
assert.match(route, /isUsefulIndexedTopicExample/);
assert.match(pool, /clinical-assistant:prompt-pool:v2/); assert.match(pool, /clinical-assistant:prompt-pool:v2/);
assert.match(pool, /redisBaseKey \+ ':all'/); assert.match(pool, /redisBaseKey \+ ':all'/);
assert.match(pool, /redisBaseKey \+ ':last-good'/);
assert.match(pool, /redisBaseKey \+ ':category:' \+ category/); assert.match(pool, /redisBaseKey \+ ':category:' \+ category/);
assert.match(pool, /redisBaseKey \+ ':age:' \+ ageBand/); assert.match(pool, /redisBaseKey \+ ':age:' \+ ageBand/);
assert.match(pool, /redisBaseKey \+ ':intent:' \+ intent/); assert.match(pool, /redisBaseKey \+ ':intent:' \+ intent/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000/); assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 \* 24 \* 60 \* 60 \* 1000/);
assert.match(route, /loadStoredPromptPool: loadLatestPromptPoolSnapshot/);
assert.match(pool, /PEDIATRIC_TAXONOMY/); assert.match(pool, /PEDIATRIC_TAXONOMY/);
assert.match(pool, /taxonomyWithQuotas\(target\)/); assert.match(pool, /taxonomyWithQuotas\(target\)/);
assert.match(pool, /age_band: ageBand/); assert.match(pool, /age_band: ageBand/);
assert.match(pool, /function hasPediatricSignal\(prompt, item\)/); assert.match(pool, /function hasPediatricSignal\(prompt, item\)/);
assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/); assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/);
assert.doesNotMatch(pool, /taxonomyFallbackExamples|taxonomySupplementExamples/);
assert.doesNotMatch(pool, /return fallbackIndexedExamples\(\)/);
});
test('clinical assistant prompt pool can be regenerated by admins', () => {
const admin = read('src/routes/adminConfig.js');
const page = read('public/components/admin.html');
const js = read('public/js/admin.js');
assert.match(admin, /\/clinical-assistant\/prompt-pool\/regenerate/);
assert.match(admin, /\/clinical-assistant\/prompt-pool\/restore/);
assert.match(page, /btn-regenerate-assistant-prompt-pool/);
assert.match(page, /btn-restore-assistant-prompt-pool/);
assert.match(js, /regenerateAssistantPromptPool/);
assert.match(js, /restoreAssistantPromptPool/);
}); });
test('clinical assistant prompt forbids relabeling other sources as named sources', () => { test('clinical assistant prompt forbids relabeling other sources as named sources', () => {

View file

@ -1,7 +1,7 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE']; const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES'];
function withEnv(overrides, fn) { function withEnv(overrides, fn) {
const previous = {}; const previous = {};
@ -36,11 +36,10 @@ test('TTS provider ignores non-LiteLLM provider overrides', () => {
test('LiteLLM TTS voice list comes from configured voice catalog', () => { test('LiteLLM TTS voice list comes from configured voice catalog', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => { withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => {
const voices = ttsProvider.getTTSVoiceLists().litellm; assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [
assert.equal(voices[0], 'sherpa/kokoro:am_adam'); 'sherpa/kokoro:am_adam',
assert.equal(voices[1], 'sherpa/kokoro:af_bella'); 'sherpa/kokoro:af_bella'
assert.equal(voices.includes('Jasper'), true); ]);
assert.equal(voices.includes('F1'), true);
}); });
}); });
@ -93,19 +92,6 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks',
}); });
}); });
test('LiteLLM TTS discovery expands Kitten and Supertonic voices', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({}, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'local-kitten-tts', model_info: { mode: 'audio_speech' } },
{ model_name: 'local-supertonic-tts', model_info: { mode: 'audio_speech' } }
], {});
assert.equal(items.some(function(item) { return item.id === 'local-kitten-tts' && item.kind === 'model'; }), true);
assert.equal(items.some(function(item) { return item.id === 'Jasper' && item.kind === 'voice'; }), true);
assert.equal(items.some(function(item) { return item.id === 'F1' && item.kind === 'voice'; }), true);
});
});
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => { test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], { assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
@ -117,38 +103,6 @@ test('LiteLLM TTS discovery still shows configured model if metadata lookup fail
]); ]);
}); });
test('LiteLLM TTS voices are scoped to the active local model', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => {
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kitten-tts'), ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-supertonic-tts'), ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']);
});
});
test('LiteLLM TTS compatibility rejects cross-model local voices', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'Bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'M1'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'Bella'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'M1'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'M1'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'Bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'hannah'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'sherpa/kokoro:af_bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'aisha'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'troy'), false);
});
test('Groq Orpheus TTS requests force wav response format', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('groq-orpheus-english'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('canopylabs/orpheus-arabic-saudi'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('local-kokoro-tts'), {});
});
test('LiteLLM headers include optional content type without exposing key value', () => { test('LiteLLM headers include optional content type without exposing key value', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => { withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {