Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
everywhere, for desktop/Linux users whose window manager doesn't expose the OS-level
`prefers-reduced-transparency` preference that the existing automatic mitigation relies on.
(#332)
- **Downloaded/total size and speed in both model-download progress UIs** (#333 item 1).
`VoiceModelDownloadModal` now shows real byte counts and transfer speed, sourced directly from
transformers.js's own progress payload (`loaded`/`total` fields it was already receiving but
discarding). `LocalAiDownloadProgress` (WebLLM text models) shows an **approximate** downloaded/
total MB and speed, derived from the existing 0-1 progress fraction × a new per-model known-size
table (`WEBLLM_MODEL_APPROX_MB`) — the installed `@mlc-ai/web-llm`'s own progress callback exposes
no structured byte counts, so this is clearly labeled as an estimate, not measured telemetry.

### Changed

Expand Down Expand Up @@ -101,6 +108,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
scroll-sync gaps: the mirror now resets to the top on a section switch instead of showing a stale
offset, and re-syncs after debounced/deferred content growth instead of staying clamped to a
since-invalid scroll range. (#341, #344)
- **Voice model download progress bar looked stuck at ~95% for most of the download.**
`voiceCommandService.ts`'s download progress handler treated transformers.js's `progress` field
as if it were already a 0-1 fraction; it's actually 0-100 (percent), so the existing
`Math.min(0.95, pct)` safety clamp kicked in almost immediately (any progress value over 0.95%,
i.e. after the very first chunk of the download) and stayed there until the download's final
"complete" dispatch. Now derives the fraction from the payload's real `loaded`/`total` byte
counts (falling back to `progress / 100` only when those are absent). (#333 item 1)

### Docs

Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
<img src="https://img.shields.io/badge/Version-v1.26.0-6366F1" alt="v1.26.0">
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2915_keys-0EA5E9" alt="i18n 19 locales — 2915 keys">
<img src="https://img.shields.io/badge/Tests-6477%2B_%2F_548_files-22C55E" alt="6477+ tests / 548 files">
<img src="https://img.shields.io/badge/i18n-19_locales-2919_keys-0EA5E9" alt="i18n 19 locales — 2919 keys">
<img src="https://img.shields.io/badge/Tests-6477%2B_%2F_549_files-22C55E" alt="6477+ tests / 549 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -397,7 +397,7 @@ Infrastructure-level features that keep the app fast and extensible as projects

### 🌐 Full Multi-Language Support

Shipped UI locales with **2915 i18n keys** across all 19 languages — zero hardcoded user-facing strings:
Shipped UI locales with **2919 i18n keys** across all 19 languages — zero hardcoded user-facing strings:

- 🇩🇪 **German** (Deutsch)
- 🇬🇧 **English**
Expand Down Expand Up @@ -506,8 +506,8 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **PDF Export** | jsPDF | Client-side, configurable PDF document generation |
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2915 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (6477+ tests / 548 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2919 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (6477+ tests / 549 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -545,7 +545,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (6477+ tests, 548 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (6477+ tests, 549 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -706,9 +706,9 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-07-30, CI-reported):**
- **6477+ unit tests** across **548 test files** — all passing
- **6477+ unit tests** across **549 test files** — all passing
- Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2915 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
- i18n: **2919 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

**CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist.

Expand Down
31 changes: 29 additions & 2 deletions components/settings/LocalAiDownloadProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import {
inferenceProgressEmitter,
type WebLlmLoadProgress,
} from '../../services/ai/inferenceProgressEmitter';
import { formatMegabytes, megabytesPerSecond } from '../../services/downloadProgressFormat';
import { abortActivePreload, retryLastPreload } from '../../services/localAiFacade';

export const LocalAiDownloadProgress: FC = () => {
const { t } = useTranslation();
const { t, formatNumber } = useTranslation();
const titleId = useId();
const cancelRef = useRef<HTMLButtonElement>(null);
const [progress, setProgress] = useState<WebLlmLoadProgress>(
Expand Down Expand Up @@ -46,6 +47,24 @@ export const LocalAiDownloadProgress: FC = () => {
? `${progressPct}% ${t<string>('settings.ai.localAi.downloadedLabel')}, ${etaText}`
: `${progressPct}%`;

// QNBS-v3 (#333 item 1): approximate — see WebLlmLoadProgress's own doc comment. Only shown once
// both bounds are known (null whenever the active model id isn't in WEBLLM_MODEL_APPROX_MB).
// QNBS-v3 (#333/Qodo): "~" prefix marks these as derived estimates, unlike the voice download UI's
// measured bytes — avoids a new i18n key by reusing the tilde convention already in model labels.
const sizeText =
progress.loadedBytes != null && progress.totalBytes != null
? t<string>('settings.ai.localAi.downloadSize', {
loaded: `~${formatMegabytes(progress.loadedBytes)}`,
total: `~${formatMegabytes(progress.totalBytes)}`,
})
: '';
const speedText =
progress.bytesPerSecond != null
? t<string>('settings.ai.localAi.downloadSpeed', {
speed: `~${formatNumber(megabytesPerSecond(progress.bytesPerSecond), { minimumFractionDigits: 1, maximumFractionDigits: 1 })}`,
})
: '';

function handleCancel() {
// QNBS-v3: actually abort the in-flight preload/worker task — not just hide the modal.
abortActivePreload();
Expand Down Expand Up @@ -138,10 +157,18 @@ export const LocalAiDownloadProgress: FC = () => {
/>
</div>

<div className="mb-4 flex justify-between text-xs text-[var(--sc-text-muted)]">
<div
className={`flex justify-between text-xs text-[var(--sc-text-muted)] ${sizeText || speedText ? 'mb-1' : 'mb-4'}`}
>
<span>{progressPct}%</span>
{etaText && <span>{etaText}</span>}
</div>
{(sizeText || speedText) && (
<div className="mb-4 flex justify-between text-xs text-[var(--sc-text-muted)]">
<span>{sizeText}</span>
{speedText && <span>{speedText}</span>}
</div>
)}

<button
ref={cancelRef}
Expand Down
67 changes: 64 additions & 3 deletions components/voice/VoiceModelDownloadModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../app/hooks';
import { settingsActions } from '../../features/settings/settingsSlice';
import { useTranslation } from '../../hooks/useTranslation';
import { formatMegabytes, megabytesPerSecond } from '../../services/downloadProgressFormat';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
import { Progress } from '../ui/Progress';
Expand All @@ -28,13 +29,41 @@ export const VoiceModelDownloadModal = React.memo(function VoiceModelDownloadMod
onClose,
modelType,
}: VoiceModelDownloadModalProps) {
const { t } = useTranslation();
const { t, formatNumber } = useTranslation();
const dispatch = useAppDispatch();
const progress = useAppSelector((s) => s.settings.voice.wasmModelDownloadProgress ?? 0);
// QNBS-v3 (#333 item 1): real bytes from transformers.js's own progress payload (not an
// approximation — see wasmModelDownloadLoadedBytes's doc comment in types.ts).
const loadedBytes = useAppSelector((s) => s.settings.voice.wasmModelDownloadLoadedBytes);
const totalBytes = useAppSelector((s) => s.settings.voice.wasmModelDownloadTotalBytes);
Comment thread
qnbs marked this conversation as resolved.
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
// QNBS-v3: CodeAnt P2-3 — AbortController lets cancel button stop an in-flight download
const abortRef = useRef<AbortController | null>(null);
// QNBS-v3 (#333/CodeRabbit+Qodo): delta-based rate from consecutive byte samples — the prior
// cumulative-bytes/elapsed-since-start calc included startup time and smoothed out real throughput
// changes into a misleading average, not a live rate.
const prevSampleRef = useRef<{ bytes: number; atMs: number } | null>(null);
const [bytesPerSecond, setBytesPerSecond] = useState<number | null>(null);

useEffect(() => {
if (!isDownloading) {
prevSampleRef.current = null;
setBytesPerSecond(null);
return;
}
if (loadedBytes == null) return;
const now = Date.now();
const prev = prevSampleRef.current;
if (prev) {
const elapsedSeconds = (now - prev.atMs) / 1000;
const deltaBytes = loadedBytes - prev.bytes;
if (elapsedSeconds > 0.5 && deltaBytes >= 0) {
setBytesPerSecond(deltaBytes / elapsedSeconds);
}
}
prevSampleRef.current = { bytes: loadedBytes, atMs: now };
}, [isDownloading, loadedBytes]);

const handleDownload = useCallback(async () => {
const ctrl = new AbortController();
Expand Down Expand Up @@ -71,8 +100,15 @@ export const VoiceModelDownloadModal = React.memo(function VoiceModelDownloadMod
abortRef.current?.abort();
abortRef.current = null;
setIsDownloading(false);
// QNBS-v3: CodeAnt — reset progress so a reopened modal auto-starts (it only fires at progress 0).
dispatch(settingsActions.setVoiceSettings({ wasmModelDownloadProgress: 0 }));
// QNBS-v3 (#333/CodeAnt+Qodo): reset progress AND byte counts — the service's own abort branch
// doesn't dispatch, so a cancel mid-download would otherwise leave stale bytes for the next attempt.
dispatch(
settingsActions.setVoiceSettings({
wasmModelDownloadProgress: 0,
wasmModelDownloadLoadedBytes: undefined,
wasmModelDownloadTotalBytes: undefined,
}),
);
onClose();
}, [dispatch, onClose]);

Expand All @@ -87,6 +123,25 @@ export const VoiceModelDownloadModal = React.memo(function VoiceModelDownloadMod
const modelName = modelType === 'stt' ? 'Whisper (STT)' : 'Kokoro (TTS)';
const modelSize = modelType === 'stt' ? MODEL_SIZES.whisper : MODEL_SIZES.kokoro;

// QNBS-v3 (#333 item 1): real byte counts once the first progress tick with them has arrived;
// MODEL_SIZES above is only the pre-download estimate shown in the description text.
const sizeText =
loadedBytes != null && totalBytes != null
? t('voice.modelDownload.progressBytes', {
loaded: formatMegabytes(loadedBytes),
total: formatMegabytes(totalBytes),
})
: '';
const speedText =
bytesPerSecond != null
? t('voice.modelDownload.speed', {
speed: formatNumber(megabytesPerSecond(bytesPerSecond), {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
})
: '';

return (
<Modal
isOpen={isOpen}
Expand All @@ -109,6 +164,12 @@ export const VoiceModelDownloadModal = React.memo(function VoiceModelDownloadMod
<p className="text-xs text-[var(--sc-text-tertiary)]" aria-live="polite">
{t('voice.modelDownload.progress', { percent: String(Math.round(progress * 100)) })}
</p>
{(sizeText || speedText) && (
<p className="flex justify-between text-xs text-[var(--sc-text-tertiary)]">
<span>{sizeText}</span>
{speedText && <span>{speedText}</span>}
</p>
)}
</>
)}

Expand Down
4 changes: 4 additions & 0 deletions locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@
"settings.ai.localAi.downloadCancelled": "أُلغي التنزيل",
"settings.ai.localAi.downloadEta": "يتبقّى ~{{seconds}} ثانية",
"settings.ai.localAi.downloadFailed": "Download failed. Please try again.",
"settings.ai.localAi.downloadSize": "{{loaded}} MB of {{total}} MB",
"settings.ai.localAi.downloadSpeed": "{{speed}} MB/s",
"settings.ai.localAi.downloadTitle": "جارٍ تنزيل نموذج الذكاء الاصطناعي…",
"settings.ai.localAi.downloadedLabel": "مُنزَّل",
"settings.ai.localAi.downloadingButton": "Downloading…",
Expand Down Expand Up @@ -830,6 +832,8 @@
"voice.modelDownload.description": "جاري تحميل نموذج {{model}} ({{size}} ميغابايت). يُمكّن ذلك المعالجة الصوتية المحلية دون إرسال إلى السحابة.",
"voice.modelDownload.error": "فشل التحميل: {{error}}",
"voice.modelDownload.progress": "{{percent}}% مكتمل",
"voice.modelDownload.progressBytes": "{{loaded}} MB of {{total}} MB",
"voice.modelDownload.retry": "إعادة المحاولة",
"voice.modelDownload.speed": "{{speed}} MB/s",
"voice.modelDownload.title": "تحميل نموذج الصوت"
}
4 changes: 4 additions & 0 deletions locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@
"settings.ai.localAi.downloadCancelled": "Download abgebrochen",
"settings.ai.localAi.downloadEta": "ca. {{seconds}} Sek. verbleibend",
"settings.ai.localAi.downloadFailed": "Download fehlgeschlagen. Bitte versuche es erneut.",
"settings.ai.localAi.downloadSize": "{{loaded}} MB von {{total}} MB",
"settings.ai.localAi.downloadSpeed": "{{speed}} MB/s",
"settings.ai.localAi.downloadTitle": "KI-Modell wird heruntergeladen…",
"settings.ai.localAi.downloadedLabel": "heruntergeladen",
"settings.ai.localAi.downloadingButton": "Wird heruntergeladen…",
Expand Down Expand Up @@ -830,6 +832,8 @@
"voice.modelDownload.description": "Lade {{model}}-Modell ({{size}} MB) herunter. Dies ermöglicht lokale Sprachverarbeitung ohne Cloud-Routing.",
"voice.modelDownload.error": "Download fehlgeschlagen: {{error}}",
"voice.modelDownload.progress": "{{percent}}% abgeschlossen",
"voice.modelDownload.progressBytes": "{{loaded}} MB von {{total}} MB",
"voice.modelDownload.retry": "Wiederholen",
"voice.modelDownload.speed": "{{speed}} MB/s",
"voice.modelDownload.title": "Sprachmodell-Download"
}
4 changes: 4 additions & 0 deletions locales/el/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@
"settings.ai.localAi.downloadCancelled": "Η λήψη ακυρώθηκε",
"settings.ai.localAi.downloadEta": "περίπου. Απομένουν {{seconds}}s",
"settings.ai.localAi.downloadFailed": "Η λήψη απέτυχε. Δοκιμάστε ξανά.",
"settings.ai.localAi.downloadSize": "{{loaded}} MB από {{total}} MB",
"settings.ai.localAi.downloadSpeed": "{{speed}} MB/s",
"settings.ai.localAi.downloadTitle": "Λήψη μοντέλου AI…",
"settings.ai.localAi.downloadedLabel": "κατεβάσει",
"settings.ai.localAi.downloadingButton": "Λήψη…",
Expand Down Expand Up @@ -830,6 +832,8 @@
"voice.modelDownload.description": "Λήψη μοντέλου {{model}} ({{size}} MB). Αυτό επιτρέπει την τοπική επεξεργασία φωνής χωρίς δρομολόγηση cloud.",
"voice.modelDownload.error": "Η λήψη απέτυχε: {{error}}",
"voice.modelDownload.progress": "{{percent}}% ολοκληρώθηκε",
"voice.modelDownload.progressBytes": "{{loaded}} MB από {{total}} MB",
"voice.modelDownload.retry": "Δοκιμάζω πάλι",
"voice.modelDownload.speed": "{{speed}} MB/s",
"voice.modelDownload.title": "Λήψη φωνητικού μοντέλου"
}
4 changes: 4 additions & 0 deletions locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@
"settings.ai.localAi.downloadCancelled": "Download cancelled",
"settings.ai.localAi.downloadEta": "approx. {{seconds}}s remaining",
"settings.ai.localAi.downloadFailed": "Download failed. Please try again.",
"settings.ai.localAi.downloadSize": "{{loaded}} MB of {{total}} MB",
"settings.ai.localAi.downloadSpeed": "{{speed}} MB/s",
"settings.ai.localAi.downloadTitle": "Downloading AI model…",
"settings.ai.localAi.downloadedLabel": "downloaded",
"settings.ai.localAi.downloadingButton": "Downloading…",
Expand Down Expand Up @@ -830,6 +832,8 @@
"voice.modelDownload.description": "Downloading {{model}} model ({{size}} MB). This enables local voice processing without cloud routing.",
"voice.modelDownload.error": "Download failed: {{error}}",
"voice.modelDownload.progress": "{{percent}}% complete",
"voice.modelDownload.progressBytes": "{{loaded}} MB of {{total}} MB",
"voice.modelDownload.retry": "Retry",
"voice.modelDownload.speed": "{{speed}} MB/s",
"voice.modelDownload.title": "Voice Model Download"
}
Loading
Loading