Compare commits

..

11 commits

Author SHA1 Message Date
Daniel
f556d50a09 Remove the --gh flag from release.sh
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m46s
release.sh --gh ran `gh release create` against GitHub. This project
publishes releases to Forgejo, and CI does it automatically on a v* tag
push (.forgejo/workflows/android-apk.yml attaches the signed APK for
Obtainium). The flag could not do anything useful here, and having it in
the usage text implied a manual publish step that does not exist.

Drops the flag, its DO_RELEASE branch, and the usage/header references.
--push remains the only option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:52:04 +02:00
Daniel
3fb4c10f2b Point the APK download link at Forgejo
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m48s
The login page linked to
github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest, which returns
404 — releases are published to git.danvics.com by the Forgejo APK
workflow. Verified: the Forgejo URL returns 200, the GitHub one 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:17:29 +02:00
Daniel
4613a27879 Release v7.14.16
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 3m13s
2026-07-31 01:12:47 +02:00
Daniel
f31afcdbf4 Fix biometric login, and keep recording alive across screen lock
Biometric sign-in has never worked. auth.js drove
window.Capacitor.Plugins.NativeBiometric — the API of
capacitor-native-biometric, which is not a dependency of this project. The
installed plugin is @aparajita/capacitor-biometric-auth, registered as
BiometricAuthNative with an entirely different API and no credential
storage at all. bioPlugin() therefore always returned null, bioAvailable()
always resolved {ok:false}, and the button was never revealed.

Rewritten against what is actually installed, with no new dependency:
BiometricAuthNative (checkBiometry/authenticate) presents the prompt, and
the already-working SecureStoragePlugin — via the window.SecureStorage
wrapper — holds the credentials. Credentials are only read after
authenticate() resolves, so the OS still gates access. biometryType is a
numeric enum in this plugin, so the old FACE_ID/TOUCH_ID string maps are
replaced with a single lookup exposed as typeName.

Secure storage itself was fine and is unchanged: SecureStoragePlugin
matches the name the wrapper looks up and is registered in
capacitor.settings.gradle.

Recording across screen lock: window.nativeKeepAwake() called Capacitor's
KeepAwake plugin, which is also not installed here, so it silently did
nothing and the device slept mid-encounter — taking the WebView's
MediaRecorder with it. keepAwake() is now a method on the existing
NativeRecording JavascriptInterface, which sets FLAG_KEEP_SCREEN_ON, and
is bound to the WebView so it survives the launcher's navigation to the
remote origin. The Capacitor plugin remains a fallback.

If the screen is locked anyway (power button, incoming call), the activity
pauses and Chromium throttles timers for hidden WebViews, starving
MediaRecorder's chunk delivery. MainActivity now calls resumeTimers() on
pause while recording. The foreground service was already correct — it
holds a partial wake lock and declares FOREGROUND_SERVICE_TYPE_MICROPHONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:12:47 +02:00
Daniel
a814d2a2c2 Fix release.sh pushing to a remote that does not exist
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m27s
--push ran `git push origin HEAD`, but this repo's only remote is named
forgejo — so the flag failed after the script had already committed and
tagged, leaving the release half-done. Resolve the remote instead:
prefer forgejo, fall back to origin, else use the only remote present,
and fail with a clear message if there is none.

Also corrects the header comment, which claimed the script does not build
the APK and pointed at --gh / gh CLI. Forgejo CI builds the APK on every
branch push and attaches it to a Forgejo release on a v* tag push, which
is what Obtainium tracks. --gh is a leftover from the GitHub era.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:05:43 +02:00
Daniel
bca107846e Release v7.14.15
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 4m43s
2026-07-30 17:34:39 +02:00
Daniel
524ad40d49 Match TTS voices to the selected LiteLLM model
Voice lists were a single flat set from LITELLM_TTS_VOICES, so picking a
model could leave an incompatible voice selected and the request would
fail at the gateway. Voices are now resolved per model family (Kokoro,
Kitten, Supertonic, Groq Orpheus EN/AR), with a compatibility check that
falls back through user → admin → env → first valid voice. Groq Orpheus
requests also pin response_format to wav.

Also refreshes the cardiac/respiratory auscultation samples, extends the
well-visit component, and fixes the Android launch theme background
(@null → colorPrimary) so the splash does not flash through.

NOTE: this is in-progress work that was already sitting uncommitted in
the working tree; it is committed here as-is so the tree was clean for
the release bump.
2026-07-30 17:34:34 +02:00
Daniel
82ed46d01b Fix Turnstile in the mobile app; drop it from login
The Turnstile challenge failed reliably inside the Capacitor WebView,
which blocked login and registration from the Android app.

Three separate causes:

1. Android WebView blocks third-party cookies by default. Turnstile runs
   in a cross-origin iframe from challenges.cloudflare.com and needs its
   own storage, so the widget never emitted a token. MainActivity now
   calls setAcceptThirdPartyCookies on the app's own WebView.

2. The register handler read the Turnstile response with an unscoped
   document.querySelector, which matched the *login* widget's input (it
   comes first in the DOM). Registration therefore submitted the login
   widget's token — single-use with a 5 minute expiry, so any prior login
   attempt or slow signup made it fail server-side.

3. The register and forgot-password widgets auto-rendered inside forms
   that start at display:none, where Turnstile does not reliably complete
   a challenge, and nothing re-rendered them when the form was shown.

Widgets are now rendered explicitly when their form first becomes
visible, and tokens are captured from the render callback instead of
being read back out of the injected input — which makes the unscoped
lookup in (2) structurally impossible. Added error/expired/timeout
callbacks so a widget failure surfaces the Cloudflare error code instead
of failing silently behind a generic toast.

Login is no longer gated at all. It is the path mobile users hit
constantly, and it is already covered by a 10-per-15-min per-IP rate
limit, a constant-time credential check, and TOTP 2FA. Registration and
password reset — the endpoints that actually attract bots — stay gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:22:03 +02:00
Daniel
bee9361c1d Fix authenticated mobile image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m39s
2026-06-09 16:02:11 +02:00
Daniel
2ca969e099 Fix generated image mobile downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m21s
2026-06-09 15:20:06 +02:00
Daniel
80139d9a82 Prevent mobile image download preview fallback
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
2026-06-09 03:11:35 +02:00
38 changed files with 680 additions and 145 deletions

View file

@ -103,8 +103,7 @@ 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,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
## Turnstile (Cloudflare bot protection) ## Turnstile (Cloudflare bot protection)
Applied to `/api/auth/login`, `/register`, `/forgot-password` when Applied to `/api/auth/register` and `/api/auth/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,14 +2,15 @@
// 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 UI's login form is gated by a Cloudflare Turnstile token // The test does a programmatic logout (clear the ped_auth cookie,
// whose site key is hardcoded in index.html, which can't be // same effect server-side as clicking Logout) followed by a fresh
// completed in the e2e container (Turnstile rejects the non-prod // programmatic login. This exercises the same localStorage
// origin). So the test does a programmatic logout (clear the // persistence path a real logout/login would.
// ped_auth cookie, same effect server-side as clicking Logout) //
// followed by a fresh programmatic login — this exercises the // (Historically this was a workaround for the Turnstile challenge on
// same localStorage persistence path a real logout/login would, // the login form, which could not be completed in the e2e container.
// without depending on the bot challenge. // Login is no longer gated, but driving it programmatically keeps
// 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 714011 versionCode 714016
versionName "7.14.11" versionName "7.14.16"
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,6 +15,8 @@ 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;
@ -36,6 +38,11 @@ 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);
@ -47,6 +54,9 @@ 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();
@ -60,6 +70,79 @@ 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() {
@ -124,6 +207,7 @@ 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
@ -131,6 +215,17 @@ 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">@null</item> <item name="android:background">@color/colorPrimary</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.11", "version": "7.14.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.11", "version": "7.14.14",
"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.11", "version": "7.14.16",
"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.11", "version": "7.14.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.11", "version": "7.14.14",
"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.11", "version": "7.14.16",
"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

@ -11,6 +11,9 @@
<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>
@ -119,6 +122,65 @@
</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;">
@ -305,4 +367,3 @@
</div> </div>
</div> </div>
</div> </div>

View file

@ -397,6 +397,21 @@ 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,7 +11,11 @@
<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">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <!-- Explicit render mode: the register/forgot widgets live inside forms that
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>
@ -70,7 +74,6 @@
<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>
@ -104,7 +107,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 class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div> <div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></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>
@ -118,7 +121,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 class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div> <div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></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>
@ -131,7 +134,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://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;"> <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;">
<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

@ -600,9 +600,19 @@ 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 (Capacitor KeepAwake or InsomniaCap) // Keep screen awake during recording.
//
// 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) { function renderGeneratedImage(src, alt, downloadUrl) {
var id = 'img-' + (++generatedImageSeq); var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = src; generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' };
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,7 +16,8 @@ export function createAssistantImageStore() {
} }
function openImagePreview(id) { function openImagePreview(id) {
var src = generatedImages[id]; var item = 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');
@ -41,14 +42,24 @@ export function createAssistantImageStore() {
} }
async function downloadImage(id) { async function downloadImage(id) {
var src = generatedImages[id]; var item = 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');
@ -123,7 +134,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. Opening browser download instead.', 'error'); if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error');
return false; return false;
} }
} }
@ -136,10 +147,14 @@ 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) ? 'cancelled' : 'unavailable'; } } catch (e) { return isShareCancel(e) ? 'unavailable' : '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');
@ -150,10 +165,79 @@ async function downloadWithBrowser(src) {
a.click(); a.click();
a.remove(); a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000); setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
if (isMobileBrowser() && typeof window.showToast === 'function') { async function downloadFromServer(url) {
window.showToast('If the download did not start, use Preview and long-press the image to save it.', 'info'); 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 (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error');
return;
} }
downloadBlobWithAnchor(blob, name);
}
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() {
@ -172,6 +256,10 @@ 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,50 +47,86 @@ 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.
var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item // Two plugins cooperate here, because the one that does the biometric
// 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.NativeBiometric; var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.BiometricAuthNative;
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.isAvailable() return p.checkBiometry()
.then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; }) .then(function (r) {
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
// verifyIdentity prompt path entirely so we don't rattle the user. // 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) {
var p = bioPlugin(); if (!bioPlugin()) 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.setCredentials({ username: email, password: password, server: BIO_SERVER }) return Promise.resolve(
.then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} }); window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password }))
).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'));
return p.verifyIdentity({ if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable'));
// 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',
title: 'PedScribe', androidTitle: 'PedScribe',
subtitle: 'Use biometric to sign in', androidSubtitle: 'Use biometric to sign in',
description: 'Confirm your identity to continue.' cancelTitle: 'Use password',
allowDeviceCredential: false
}).then(function () { }).then(function () {
return p.getCredentials({ server: BIO_SERVER }); return window.SecureStorage.get(BIO_CREDS_KEY);
}).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 (!p) return Promise.resolve(); if (!window.SecureStorage) return Promise.resolve();
return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ }); return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).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 };
@ -113,9 +149,8 @@ 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.type) { if (label && 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 = 'Sign in with ' + s.typeName;
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 = ''; }
@ -214,6 +249,71 @@ 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() {
@ -226,12 +326,14 @@ 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) {
@ -527,7 +629,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 (turnstile, 2FA prompt, error handling, // form so all the existing flow (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');
@ -544,10 +646,7 @@ 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 (turnstile token, 2FA, session storage, etc.) // existing handling (2FA, session storage, etc.) runs unchanged.
// 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 }));
}) })
@ -577,17 +676,9 @@ 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, turnstileToken: loginToken }; var body = { email: email, password: password };
if (totpCode) body.totpCode = totpCode; if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', { fetch('/api/auth/login', {
@ -625,7 +716,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 = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric'; var typeName = s.typeName || '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)
@ -637,14 +728,12 @@ 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;
@ -703,9 +792,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Cloudflare Turnstile verification // Cloudflare Turnstile verification
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]'); var regToken = turnstileToken('register');
var turnstileToken = turnstileResponse ? turnstileResponse.value : ''; if (!regToken) {
if (!turnstileToken) {
showToast('Please complete the verification challenge', 'error'); showToast('Please complete the verification challenge', 'error');
return false; return false;
} }
@ -715,7 +803,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: turnstileToken }) body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@ -729,17 +817,20 @@ 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');
if (window.turnstile) turnstile.reset(); resetTurnstile('register');
} }
}) })
.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');
if (window.turnstile) turnstile.reset(); resetTurnstile('register');
}); });
return false; return false;
@ -756,8 +847,7 @@ 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 forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]'); var forgotToken = turnstileToken('forgot');
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;
@ -774,12 +864,13 @@ 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');
if (window.turnstile) turnstile.reset('#turnstile-forgot'); resetTurnstile('forgot');
}); });
return false; return false;

View file

@ -492,10 +492,11 @@ 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();
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual'); var downloadUrl = data.downloadUrl || '';
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'); var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
replaceLoadingMessage(loading, html, [], [], true); replaceLoadingMessage(loading, html, [], [], true);
} }
}) })

View file

@ -5,7 +5,6 @@
# 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
@ -13,31 +12,32 @@
# 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 (run `cd mobile/android && ./gradlew ...` # - Build the Android APK itself. Forgejo CI does that
# yourself). This script just marks the version so the build tags # (.forgejo/workflows/android-apk.yml): any branch push builds a signed
# correctly. # APK as a 30-day workflow artifact, and a v* tag push additionally
# 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] [--gh]" echo "usage: $0 <version> [--push]"
echo " example: $0 6.1.1 --push --gh" echo " example: $0 6.1.1 --push"
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,30 +90,25 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
echo " tagged v${VERSION}" echo " tagged v${VERSION}"
if $PUSH; then if $PUSH; then
git push origin HEAD # This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back
git push origin "v${VERSION}" # to origin, otherwise use the only remote there is — so this keeps working
echo " pushed to origin" # if the remote is ever renamed. Pushing the tag is what makes CI attach the
fi # signed APK to a Forgejo release for Obtainium; the branch push alone only
# builds it as a 30-day workflow artifact.
if $DO_RELEASE; then REMOTE=""
if ! command -v gh >/dev/null; then for candidate in forgejo origin; do
echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2 if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi
else done
APK="mobile/android/app/build/outputs/apk/release/app-release.apk" if [[ -z "$REMOTE" ]]; then
if [[ -f "$APK" ]]; then REMOTE=$(git remote | head -1)
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 fi
if [[ -z "$REMOTE" ]]; then
echo "ERROR: no git remote configured — cannot push. Commit and tag are still local." >&2
exit 1
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

@ -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, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider'); var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = 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');
@ -550,12 +550,17 @@ 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: dbVoice || envVoice, currentVoice: currentVoice,
currentModel: dbModel || envModel, currentModel: currentModel,
dbVoice: dbVoice, dbVoice: dbVoice,
dbModel: dbModel, dbModel: dbModel,
envVoice: envVoice, envVoice: envVoice,
@ -563,7 +568,9 @@ router.get('/config/tts', async function(req, res) {
configured: { configured: {
litellm: !!process.env.LITELLM_API_BASE litellm: !!process.env.LITELLM_API_BASE
}, },
voices: getTTSVoiceLists() voices: {
litellm: voices
}
}); });
} catch (e) { res.status(500).json({ error: 'Request failed' }); } } catch (e) { res.status(500).json({ error: 'Request failed' }); }
}); });
@ -616,11 +623,15 @@ 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 usedVoice = voice || adminVoice || process.env.LITELLM_TTS_VOICE || ''; var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
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'),
{ model: ttsModel, voice: usedVoice, input: text }, payload,
{ 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,19 +299,15 @@ 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, turnstileToken } = req.body; var { email, password, totpCode } = 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' });
// Cloudflare Turnstile verification // No Turnstile on login. The widget could not reliably complete a
if (process.env.TURNSTILE_SECRET_KEY) { // challenge inside the Capacitor WebView, which locked mobile users out.
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' }); // Brute-force cover here comes from the 10-per-15-min per-IP rate limit
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { // (server.js), the constant-time bcrypt comparison below, and TOTP 2FA.
method: 'POST', headers: { 'Content-Type': 'application/json' }, // Registration and password reset — the endpoints that actually attract
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip }) // bots — are still gated.
});
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

@ -325,6 +325,7 @@ 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) {
@ -332,6 +333,22 @@ 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();

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 { getTTSProvider } = require('../utils/ttsProvider'); var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, 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,19 +22,22 @@ 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 ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || ''; var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
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({ model: ttsModel, input: text, voice: ttsVoice }) body: JSON.stringify(payload)
}); });
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 { getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider'); var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider');
router.use(authMiddleware); router.use(authMiddleware);
@ -49,14 +49,18 @@ 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 = getTTSVoiceLists().litellm.map(function(voice) { return { value: voice, label: voice }; }); var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).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

@ -7,6 +7,20 @@ 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';
@ -20,10 +34,58 @@ function getTTSEnvProvider() {
function getTTSVoiceLists() { function getTTSVoiceLists() {
return { return {
litellm: parseList(process.env.LITELLM_TTS_VOICES) litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_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';
@ -56,6 +118,26 @@ 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;
} }
@ -64,7 +146,10 @@ module.exports = {
getLiteLLMTTSDiscoveryItems, getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders, getLiteLLMHeaders,
getLiteLLMTTSModels, getLiteLLMTTSModels,
getLiteLLMTTSRequestOptions,
getLiteLLMTTSVoicesForModel,
getTTSProvider, getTTSProvider,
getTTSVoiceLists, getTTSVoiceLists,
isLiteLLMTTSVoiceCompatible,
isLiteLLMTTSModel isLiteLLMTTSModel
}; };

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']; const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE'];
function withEnv(overrides, fn) { function withEnv(overrides, fn) {
const previous = {}; const previous = {};
@ -36,10 +36,11 @@ 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' }, () => {
assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [ const voices = ttsProvider.getTTSVoiceLists().litellm;
'sherpa/kokoro:am_adam', assert.equal(voices[0], 'sherpa/kokoro:am_adam');
'sherpa/kokoro:af_bella' assert.equal(voices[1], 'sherpa/kokoro:af_bella');
]); assert.equal(voices.includes('Jasper'), true);
assert.equal(voices.includes('F1'), true);
}); });
}); });
@ -92,6 +93,19 @@ 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([], {
@ -103,6 +117,38 @@ 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' }, () => {