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
54 changes: 54 additions & 0 deletions .github/workflows/voice-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# QNBS-v3: P1-2 — Nightly, non-blocking real-inference check for the Whisper WASM STT pipeline.
# Runs the production model path (real 42 MB download + pipeline init from the HF CDN),
# which the deterministic blocking suite (whisper-stt.spec.ts) deliberately mocks out.
name: 🎙️ Voice Nightly (real Whisper)

on:
schedule:
# 03:17 UTC daily — off-peak, offset from other cron jobs.
- cron: '17 3 * * *'
workflow_dispatch: {}

# QNBS-v3: top-level read-only; no job needs write here.
permissions:
contents: read

concurrency:
group: voice-nightly-${{ github.ref }}
cancel-in-progress: true

jobs:
voice-real:
name: 🎙️ Whisper real download + pipeline
runs-on: ubuntu-latest
timeout-minutes: 30
# Never gate anything — informational nightly signal only.
continue-on-error: true
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: ./.github/actions/setup

- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Run real Whisper E2E
run: pnpm exec playwright test tests/e2e/deep/voice/whisper-real.spec.ts --project=chromium
env:
CI: 'true'
RUN_DEEP_E2E: '1'
RUN_REAL_VOICE_E2E: '1'

- name: Upload report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: voice-nightly-report
path: tests/e2e/html-report/
if-no-files-found: warn
retention-days: 7
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

_Nothing yet._
### Added

- **WebLLM worker offload (P1-1, ADR-0005):** `@mlc-ai/web-llm` (WebGPU) inference now runs in a
dedicated WorkerBus v2 `webllm` pool (`workers/v2/webllm.worker.ts`, capability `inference.webllm`)
instead of inline on the main thread. Worker-first with an automatic main-thread fallback on
`NO_WEBGPU` / worker-spawn failure / circuit-open, decoupled from `enableWorkerBusV2`. GPU mutex +
tab-leader election stay on the main thread; loading progress bridges to `inferenceProgressEmitter`
so the UX is unchanged.
- **Whisper WASM STT end-to-end tests (P1-2):** A deterministic, deep-E2E suite
(`tests/e2e/deep/voice/whisper-stt.spec.ts`) exercises the full voice orchestration — simulated
model download (progress / cancel / error → retry), STT → intent → command-dispatch navigation, and
stop-listening stability — via a guarded test seam (`services/voice/voiceTestSeam.ts`). A
non-blocking nightly workflow (`voice-nightly.yml`) runs the **real** Whisper download + pipeline
init against the live CDN.

### Changed

- **Voice hardening (v1.21 follow-up):** Transcript redacted from the intent-engine debug log
(C-P0 — user speech is PII and the IDB log sink persists it); single-flight guard on
`VoiceCommandService.startListening` against re-entrant push-to-talk / wake-word starts; download
modal progress is now an accessible `role="progressbar"` with a polite live region (`Progress`
atom + `VoiceModelDownloadModal`).

## [1.21.0] — 2026-06-10

Expand Down
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,13 @@ services/ → External adapters; key sub-dirs:
packages/ → Internal workspace packages: ai-core (WebLLM + inference worker), ui,
collab-transport (vendor fork of y-webrtc 10.3.0 with RTCDataChannel E2E encryption),
worker-bus (typed worker pool, circuit breakers, dead-letter queue — see § WorkerBus below)
locales/ → i18n source JSON (de/en/es/fr/it/ar/he/el/ja/pt/zh × 15 modules); runtime: public/locales/<lang>/bundle.json
locales/ → i18n source JSON (de/en/es/fr/it/ar/he/el/ja/pt/zh × 20 modules); runtime: public/locales/<lang>/bundle.json
ar/ + he/ — RTL stubs behind enableRtlLayout; el/ja/pt/zh — Beta locales (P1-5)
tests/ → unit/ (Vitest) + e2e/ (Playwright); shared E2E helpers in tests/e2e/helpers.ts
types/ → Supplemental TypeScript definitions (duckdb-wasm-worker.d.ts, tauri-plugins.d.ts)
types.ts → Core shared interfaces and types (root level)
workers/ → inference.worker.ts (@huggingface/transformers v3), duckdbWorker.ts (DuckDB-WASM)
v2/ → WorkerBus v2 workers: inference.worker.ts, duckdb.worker.ts, webllm.worker.ts (P1-1, @mlc-ai/web-llm)
infra/low-end-ci/ → Local CI stack: Forgejo + act + systemd units + bash scripts
scripts/ → Build/deploy helpers (sync-deploy-base, cf-pages-deploy, graphify-update, etc.)
```
Expand Down Expand Up @@ -172,7 +173,7 @@ Wrap each major view root with `components/ui/ViewErrorBoundary.tsx` — provide

`services/ai/aiRetry.ts` — `withTransientRetry(fn, opts)` wraps any AI call with transient-error retries. Use this instead of ad-hoc retry logic.

**WebLLM / local inference:** `services/localAiFacade.ts` wraps `@mlc-ai/web-llm` (via `packages/ai-core`). Supported models: Llama 3.2 1B/3B, Phi-3.5 Mini, Gemma 2 2B. Tab-leader election via BroadcastChannel prevents multi-tab GPU contention.
**WebLLM / local inference:** `services/localAiFacade.ts` wraps `@mlc-ai/web-llm` (via `packages/ai-core`). Supported models: Llama 3.2 1B/3B, Phi-3.5 Mini, Gemma 2 2B. Tab-leader election via BroadcastChannel prevents multi-tab GPU contention. **WebLLM offload (P1-1, ADR-0005):** inference runs in the dedicated WorkerBus v2 `webllm` pool (`workers/v2/webllm.worker.ts`, capability `inference.webllm`), NOT on the main thread. `generateLocalText` is worker-first via `ensureWebLlmPool()` (decoupled from `enableWorkerBusV2`) with an automatic main-thread fallback (`runLocalTextGeneration`) on `NO_WEBGPU` / worker-spawn failure / circuit-open. GPU mutex (`gpuResourceManager`) + tab election stay main-thread, acquired before enqueue.

**Local RAG:** `services/localRagIndex.ts` + `localRagService.ts` — hybrid retrieval (60% semantic MiniLM-L6-v2 + 30% lexical + 10% recency). `ragMode: 'hybrid' | 'lexical'` in `settings.advancedAi` (default `'hybrid'`).

Expand Down Expand Up @@ -244,7 +245,7 @@ Key flags: `enableDuckDbAnalytics`, `enableVoiceSupport`, `enableProForge`, `ena

### i18n

Custom React Context in `I18nContext.tsx` — not i18next. Source locales: **de, en, es, fr, it** (core), **ar, he** (RTL stubs, B-5), **el, ja, pt, zh** (Beta, P1-5). All 12 ship as `public/locales/<lang>/bundle.json` rebuilt by `pnpm run i18n:bundle` or auto via `pnpm run i18n:check`. All user-facing strings must use `t('key.path')` from `useTranslation()`. New keys: add to **all 12** locale trees (`node scripts/check-i18n-keys.mjs --fix`), then `pnpm run i18n:bundle`. The `/i18n-key` skill targets the **5 core** locales only; update Beta/RTL locales manually afterward.
Custom React Context in `I18nContext.tsx` — not i18next. Source locales: **de, en, es, fr, it** (core), **ar, he** (RTL stubs, B-5), **el, ja, pt, zh** (Beta, P1-5). All 11 ship as `public/locales/<lang>/bundle.json` rebuilt by `pnpm run i18n:bundle` or auto via `pnpm run i18n:check`. All user-facing strings must use `t('key.path')` from `useTranslation()`. New keys: add to **all 11** locale trees (`node scripts/check-i18n-keys.mjs --fix`), then `pnpm run i18n:bundle`. The `/i18n-key` skill targets the **5 core** locales only; update Beta/RTL locales manually afterward.

**RTL stubs (B-5):** `locales/ar/` + `locales/he/` are English-fallback stubs behind `enableRtlLayout`. Full content is v2.0 community task.

Expand Down Expand Up @@ -347,7 +348,9 @@ All `.md` guides listed in **[`README.md`](README.md#-documentation-hub) § Docu

Engines defined in `services/voice/voiceTypes.ts` (`SttEngine`, `TtsEngine`, `VadEngine`, `WakeWordEngine`, `IntentEngine`). Contract: `isAvailable()` → `initialize()` → use → `dispose()`. Web Speech API fallbacks: `WebSpeechSttEngine`, `WebSpeechTtsEngine`, `WebRtcVadEngine` (zero downloads). WASM path (B-2, `enableVoiceWasm`): `WasmSttEngine` (Whisper.cpp) + `SileroVadEngine`; model download via `VoiceModelDownloadModal` + `VoiceCommandService.preloadModel(modelType)`.

**Intent engine:** `HybridIntentEngine.parse(transcript, context)` — exact match → fuzzy Jaccard + slot extraction. **Orchestrator:** `VoiceCommandService` singleton (state machine), dispatches via `runCommandById`, `appStoreRef` for Redux outside React. **Hooks:** `useVoice`, `usePushToTalk` (Ctrl+Shift+V), `useVoiceDictation`, `useVoiceAccessibility`. **Gating:** `settings.voice.enabled && featureFlags.enableVoiceSupport`.
**Intent engine:** `HybridIntentEngine.parse(transcript, context)` — exact match → fuzzy Jaccard + slot extraction. **Orchestrator:** `VoiceCommandService` singleton (state machine), dispatches via `runCommandById`, `appStoreRef` for Redux outside React. **Hooks:** `useVoice`, `usePushToTalk` (Ctrl+Shift+V), `useVoiceDictation`, `useVoiceAccessibility`. **Gating:** `settings.voice.enabled && featureFlags.enableVoiceSupport`. **Never log transcripts** (PII → IDB log sink); `startListening` has a single-flight guard (C-P1).

**Voice E2E seam (P1-2):** `services/voice/voiceTestSeam.ts` — `getVoiceTestHarness()` reads `window.__voiceTestHarness` (only ever set by Playwright `addInitScript`; undefined in production). `createSttEngine`/`createVadEngine` return injected mock engines, and `downloadVoiceModels` runs a simulated download, when the harness is present. Installers: `tests/e2e/mocks/voiceMockEngines.ts`. Deterministic suite: `tests/e2e/deep/voice/whisper-stt.spec.ts` (e2e-deep); real-inference nightly: `whisper-real.spec.ts` + `voice-nightly.yml` (`RUN_REAL_VOICE_E2E=1`). Chromium fake-media flags live in `playwright.config.ts`.

### Local inference

Expand Down
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ Status: 🔄 in progress | ⬜ open | ✅ done
- ✅ **WS-6** (F-7/F-9, `3e0aa82`) — `VENDOR-FORKS.md` CVE/OSV-coverage section (vendored y-webrtc invisible to OSV → manual process) + new `docs/COVERAGE-POLICY.md` ratchet rule.

### Carried over from v1.20.0
- **P1-1** — WebLLM Worker Offload: full GPU isolation in dedicated worker (not started, 5–7 days).
- 🔄 **P1-2** — Whisper WASM STT end-to-end: download UI ✅ + VAD→STT bridge ✅; remaining = full E2E integration test (CI-only).
- **P1-1** — WebLLM Worker Offload (ADR-0005): dedicated WorkerBus v2 `webllm` pool (`workers/v2/webllm.worker.ts`, capability `inference.webllm`); `generateLocalText` is worker-first with automatic main-thread fallback (NO_WEBGPU / spawn fail / circuit-open), decoupled from `enableWorkerBusV2` via `ensureWebLlmPool()`; GPU mutex + tab election stay main-thread; progress bridges to `inferenceProgressEmitter`. Tests: `webllmWorkerHandler.test.ts` + updated `localAiFacade.test.ts`.
- **P1-2** — Whisper WASM STT end-to-end: download UI ✅ + VAD→STT bridge ✅ + **E2E ✅** — deterministic deep suite `tests/e2e/deep/voice/whisper-stt.spec.ts` (download progress/cancel/error→retry, STT→intent→command, stop-listening) via guarded seam `services/voice/voiceTestSeam.ts`; nightly real-inference `voice-nightly.yml` + `whisper-real.spec.ts`. Remaining (follow-up): (a) real-audio transcription assertion needs a committed speech WAV (`--use-file-for-fake-audio-capture`); (b) two STT→command navigation deep tests are `test.fixme` — the headless mock-STT → push-to-talk → command-dispatch chain doesn't fire reliably under fake-media (download flow + stop-listening cover orchestration; STT→intent→command is unit-covered). Re-enable after a Playwright trace of the CI voice-init sequence.
- ✅ **P1-7** — Bundle Budget single source of truth (F-8): `package.json` `bundle:budget` = `--max-kb 6500 --max-entry-kb 4000`; `scripts/check-bundle-budget.mjs` defaults match. Real sizes (CI 2026-06-09): entry `index-*` ≈ 496 KB; largest vendor chunk `lib-*` ≈ 6 054 KB (~446 KB headroom under the 6500 per-chunk ceiling).
- ⬜ **P2-2..P2-4** — v2.0 foundation (Cloud-Sync conflict resolution, Plugin Registry Beta, ADRs 0005+).

Expand Down
6 changes: 5 additions & 1 deletion components/dashboard/GoalTrackerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ export const GoalTrackerCard: FC = () => {
/ {project.projectGoals?.totalWordCount.toLocaleString()} {t('common.words')}
</span>
</div>
<Progress value={wordCountProgress} className="h-4" />
<Progress
value={wordCountProgress}
className="h-4"
aria-label={t('dashboard.goals.title')}
/>
{wordsRemaining > 0 ? (
<p className="mt-2 text-xs text-[var(--sc-text-muted)]">
{t('dashboard.goals.wordsRemaining', { count: wordsRemaining.toLocaleString() })}
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/ProjectHealthCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const BreakdownBar: FC<{ label: string; value: number }> = ({ label, value }) =>
</span>
<span className="shrink-0 text-xs tabular-nums text-[var(--sc-text-muted)]">{value}%</span>
</div>
<Progress value={value} className="h-1.5" />
<Progress value={value} className="h-1.5" aria-label={label} />
</div>
);

Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/WritingMomentumCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const GoalRow: FC<{ label: string; current: number; goal: number; progress: numb
})}
</span>
</div>
<Progress value={progress} className="h-2.5" />
<Progress value={progress} className="h-2.5" aria-label={t('dashboard.momentum.title')} />
</div>
);
};
Expand Down
2 changes: 1 addition & 1 deletion components/ui/DuckDbMigrationBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function DuckDbMigrationBanner() {
)}
{!isRunning && !isError && (
<div className="mt-2">
<Progress value={100} />
<Progress value={100} aria-label={t('duckdb.migration.complete')} />
</div>
)}
</div>
Expand Down
15 changes: 12 additions & 3 deletions components/ui/Progress.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import type React from 'react';

interface ProgressProps {
// QNBS-v3: C-P1 / CodeAnt — an accessible name is MANDATORY for role="progressbar" (WCAG 2.2 AA
// 4.1.2 name/role/value). The union forces every caller to supply either `aria-label` or
// `aria-labelledby`, so a progress indicator can never render unnamed for screen readers.
type ProgressProps = {
value: number; // 0 to 100
className?: string;
}
} & ({ 'aria-label': string } | { 'aria-labelledby': string });

export const Progress: React.FC<ProgressProps> = ({ value, className }) => {
export const Progress: React.FC<ProgressProps> = ({ value, className, ...aria }) => {
const progress = Math.max(0, Math.min(100, value));

return (
<div
// QNBS-v3: role + value + a required name (spread from `aria`) — full name/role/value support.
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
{...aria}
className={`h-2 w-full overflow-hidden rounded-full bg-[var(--sc-surface-overlay)] ${className}`}
Comment thread
qnbs marked this conversation as resolved.
>
<div
Expand Down
18 changes: 13 additions & 5 deletions components/voice/VoiceModelDownloadModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,18 @@ 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 }));
onClose();
}, [onClose]);
}, [dispatch, onClose]);

useEffect(() => {
if (isOpen && !isDownloading && progress === 0) {
// QNBS-v3: P1-2 — guard on !error so a failed download (which resets progress to 0) does NOT
// auto-retry in a loop; the user retries via the explicit Retry button instead.
if (isOpen && !isDownloading && !error && progress === 0) {
void handleDownload();
}
}, [isOpen, isDownloading, progress, handleDownload]);
}, [isOpen, isDownloading, error, progress, handleDownload]);

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

{isDownloading && (
<>
<Progress value={Math.round(progress * 100)} />
<p className="text-xs text-[var(--sc-text-tertiary)]">
{/* QNBS-v3: C-P1 — labelled progressbar + polite live region so the percentage is announced. */}
<Progress
value={Math.round(progress * 100)}
aria-label={t('voice.modelDownload.title')}
/>
<p className="text-xs text-[var(--sc-text-tertiary)]" aria-live="polite">
{t('voice.modelDownload.progress', { percent: String(Math.round(progress * 100)) })}
</p>
</>
Expand Down
Loading
Loading