61 Commits
Author SHA1 Message Date
iamdoubz 21f3d76ed0 Merge pull request 'Feature chore bug 009' (#24) from feature_chore_bug_009 into main
Reviewed-on: #24
2026-07-15 18:13:11 -05:00
iamdoubz 19bd5815c0 chore: bump version 0.7.2 2026-07-15 18:09:07 -05:00
iamdoubz a552ddb1fa fix(ui): action items as two-row cards so the action text is always readable in the narrow pane
Text row (confirm + full-width text + delete) over a wrapping meta row
(owner, due date, reminder). Also void the seekNonce dependency read to
satisfy no-unused-expressions.
2026-07-15 17:41:38 -05:00
iamdoubz de25ac70e7 fix(notes): adopt server-side notes.md changes (speaker rename, reprocess) into the open editor buffer
Also untangles the a11y svelte-ignore comment from its prose so eslint's
no-unused-svelte-ignore stops parsing the explanation as ignore codes.
2026-07-15 17:41:37 -05:00
iamdoubz 28d9b2988a fix(spk): live split attribution + honest model reporting + notes rename rewrite (FR-SPK, T3.4)
- live diarization tick on split recordings now uses attribute_split (mic
  channel = 'You' from the first pass) instead of whole-mix clustering +
  voiceprint matching, which never reliably surfaced 'You' before stop
- attribute_split assigns via diarization::assign_split (channel-total
  overlap) so the user's speech can't be swallowed by a longer far-side span
- stop_recording overlays live user renames over the automatic name map
- rename_speaker/map_speaker_to_participant rewrite notes.md's baked-in
  **Name:** dialogue tags so the Notes pane follows the Speakers pane
- model_used records the ONNX model when the NPU/DirectML engine is routed
  (was reporting the unused ggml model id) + info log naming the encoder EP
2026-07-15 17:41:28 -05:00
iamdoubz f24adc3a59 fix(diarization): channel-total split attribution so mic speech can't be swallowed by a far span (FR-SPK) 2026-07-15 17:33:40 -05:00
iamdoubz 94425d456c docs(release): v0.7.1 release notes with SHA-256 checksums 2026-07-15 14:44:01 -05:00
iamdoubz 49cdbd6a54 chore: bump version 0.7.1 2026-07-15 14:38:31 -05:00
iamdoubz b4d72622da docs(data): close_to_tray setting in settings.json schema 2026-07-15 14:02:22 -05:00
iamdoubz c1ccdce497 docs(api): toggle_microphone_mute command + recording://mic event 2026-07-15 14:02:21 -05:00
iamdoubz a319dd9e97 feat(i18n): mute + close-to-tray strings 2026-07-15 14:01:43 -05:00
iamdoubz 6a01afbba6 feat(settings): default close_to_tray in settings store 2026-07-15 14:01:42 -05:00
iamdoubz 8c6a0ea25c feat(settings): close-to-tray toggle in Settings 2026-07-15 14:01:41 -05:00
iamdoubz 4a000f07a2 feat(app): M-key + mic mute button in recording controls 2026-07-15 14:01:40 -05:00
iamdoubz 0e2a3bb110 feat(recording): micMuted state + toggleMute synced to recording://mic 2026-07-15 13:59:04 -05:00
iamdoubz 95b3d34713 feat(api): toggleMicrophoneMute + recording://mic event + close_to_tray setting 2026-07-15 13:58:18 -05:00
iamdoubz 588354f488 fix(tray): remove duplicate trayIcon from config — one tray icon only 2026-07-15 13:57:38 -05:00
iamdoubz ee08f2bc88 feat(app): single tray with Show/Quit menu, close-to-tray, register mute cmd 2026-07-15 13:57:37 -05:00
iamdoubz a71c17965f feat(recording): toggle_microphone_mute command + recording://mic event 2026-07-15 13:57:36 -05:00
iamdoubz 3c73ec1cd4 feat(settings): close_to_tray setting (default on) 2026-07-15 13:57:35 -05:00
iamdoubz 8f805b8ef5 feat(audio): live mic mute — zero mic samples via CaptureHandle.set_muted (FR-CAP-7) 2026-07-15 13:57:33 -05:00
iamdoubz f83234bde1 docs(api): document import_media (model, background) + import://progress event 2026-07-15 13:46:50 -05:00
iamdoubz d4b1578478 feat(i18n): transcript.transcribed_with label 2026-07-15 13:46:01 -05:00
iamdoubz 185e4ec803 feat(import): show import tracker + 'transcribed with' model in meeting view 2026-07-15 13:46:00 -05:00
iamdoubz 30f4a4d3ec feat(import): init imports store for live progress tracking 2026-07-15 13:45:59 -05:00
iamdoubz 86c3587d19 feat(i18n): import model/tracker/phase strings; background import copy 2026-07-15 13:44:00 -05:00
iamdoubz d736b78ecb feat(import): model selector + ffmpeg/yt-dlp download links in add-meeting dialog 2026-07-15 13:43:26 -05:00
iamdoubz 98f6fcdc16 feat(import): ImportTracker Domino's-style 4-step progress component 2026-07-15 13:42:19 -05:00
iamdoubz 683838bef1 feat(import): imports store tracking per-meeting import phase progress 2026-07-15 13:41:08 -05:00
iamdoubz c63cabaf88 feat(import): api model param + import://progress event and types 2026-07-15 13:39:55 -05:00
iamdoubz fe355f034d feat(import): run import in background with per-phase progress + model override 2026-07-15 13:39:13 -05:00
iamdoubz e0e9377273 feat(storage): set_meeting_status for background import status transitions 2026-07-15 13:35:06 -05:00
iamdoubz a95eafe952 docs(api): enhance_notes, monitor_audio_level, stress_test_hardware + events 2026-07-15 09:50:17 -05:00
iamdoubz 5b9e0ed1f7 feat(app): register enhance_notes, monitor_audio_level, stress_test_hardware 2026-07-15 09:50:16 -05:00
iamdoubz 6c343c4204 feat(hardware): enhance_notes, monitor_audio_level, stress_test_hardware commands 2026-07-15 09:50:15 -05:00
iamdoubz 7d16c864e8 feat(ui): i18n for notes editor, vault, and hardware test/stress 2026-07-15 09:50:13 -05:00
iamdoubz e461d4eb22 feat(ui): enhanceNotes/monitorAudioLevel/stressTestHardware bindings + events + types 2026-07-15 09:50:12 -05:00
iamdoubz b14728e1c8 feat(ui): vault lock card, audio device test meter, quick hardware stress test 2026-07-15 09:50:00 -05:00
iamdoubz b7e6f54d91 feat(ui): header reshuffle — actions far-left, template beside them, drop wordmark 2026-07-15 09:49:58 -05:00
iamdoubz cf919f1499 fix(notes): refresh notes pane after re-transcribe; add AI-enhance + slash/toolbar
Re-sync the editor buffer after reprocess (was stale until restart). Adds a
Granola-style AI-enhance button (grounded in transcript, one-step undo), H3/
numbered/quote/divider toolbar buttons, and slash commands.
2026-07-15 09:49:57 -05:00
iamdoubz c616e0bfec Merge pull request 'Chores customize install' (#23) from chores_customize_install into main
Reviewed-on: #23
2026-07-14 21:40:02 -05:00
iamdoubz ecbe3e464c docs(release): v0.7.0 release notes with SHA-256 checksums 2026-07-14 20:01:04 -05:00
iamdoubz 7f6c0fac81 chore: bump version 0.7.0 2026-07-14 19:53:03 -05:00
iamdoubz 6fa9916f7e docs(readme): opt-in startup, install scope, enterprise deployment pointer 2026-07-14 16:27:02 -05:00
iamdoubz d78a426a7e docs(api): set_auto_start command 2026-07-14 16:27:01 -05:00
iamdoubz 1b36df020a docs(data-model): auto_start setting + wa-defaults.ini first-run seeding 2026-07-14 16:27:00 -05:00
iamdoubz 4af008da0f docs(deploy): enterprise deployment guide (wa-defaults.ini, install flags) 2026-07-14 16:26:59 -05:00
iamdoubz 223dc5f34b docs(adr): ADR-0012 launch-at-login & enterprise deployment defaults 2026-07-14 16:26:58 -05:00
iamdoubz 27773a03e5 feat(ui): i18n for launch-at-login and hardware refresh 2026-07-14 16:24:28 -05:00
iamdoubz 203e7b1bf6 feat(ui): launch-at-login toggle + refresh-hardware button 2026-07-14 16:24:27 -05:00
iamdoubz a6e1728346 feat(ui): setAutoStart store action + auto_start default 2026-07-14 16:24:26 -05:00
iamdoubz 26808970df feat(ui): setAutoStart binding + auto_start in AppSettings 2026-07-14 16:24:25 -05:00
iamdoubz 4a63b38ee0 chore(bundle): include wa-defaults.ini in the Vulkan release resources 2026-07-14 16:21:43 -05:00
iamdoubz 5d6563231c chore(bundle): bundle wa-defaults.ini + NSIS installMode both (per-user/all-users) 2026-07-14 16:21:42 -05:00
iamdoubz 2edd0382d3 feat(deploy): ship commented wa-defaults.ini template (no-op until edited) 2026-07-14 16:21:41 -05:00
iamdoubz 1530bca976 feat(app): register autostart plugin, first-run deploy seed, auto_start reconcile 2026-07-14 16:21:35 -05:00
iamdoubz 55c5143de0 feat(deploy): first-run wa-defaults.ini settings seeding
Reads an admin-supplied .ini once before settings.json exists; coerces
values onto typed Settings via serde; drops secret-looking keys.
2026-07-14 16:21:34 -05:00
iamdoubz 47071b5861 feat(app): set_auto_start command + default_settings/save_settings visibility 2026-07-14 16:21:33 -05:00
iamdoubz ded5e00750 feat(app): add auto_start setting (opt-in, default off, NFR-RES-4) 2026-07-14 16:21:26 -05:00
iamdoubz 2fa3ea6824 feat(app): add tauri-plugin-autostart dependency
Pulls in the official launch-at-login plugin (per-user HKCU\Run, no admin).
Regenerated ACL/schema manifests reflect the new plugin.
2026-07-14 16:21:25 -05:00
iamdoubz 14300fccee Remove temp changelog files 2026-07-14 15:54:41 -05:00
37 changed files with 3052 additions and 507 deletions
+10 -5
View File
@@ -138,10 +138,15 @@ during setup).
On first run WA detects your hardware and picks the best transcription backend On first run WA detects your hardware and picks the best transcription backend
(**NPU → NVIDIA → AMD → Intel → CPU**). It works on your **CPU or GPU** (GPU via Vulkan) out of the (**NPU → NVIDIA → AMD → Intel → CPU**). It works on your **CPU or GPU** (GPU via Vulkan) out of the
box; to use an Intel **NPU**, open **Settings ▸ Hardware** and download the one-time NPU box; to use an Intel **NPU**, open **Settings ▸ Hardware** and download the one-time NPU
acceleration package. The app runs without admin rights, never adds itself to startup, and keeps all acceleration package. The app runs without admin rights, does not add itself to startup unless you
data under `%LOCALAPPDATA%\WhispAssist`. opt in (**Settings ▸ Recording ▸ Launch at login**), and keeps all data under
`%LOCALAPPDATA%\WhispAssist`.
Prefer the NSIS installer? Grab **`WhispAssist_<version>_x64-setup.exe`** from the same page. Prefer the NSIS installer? Grab **`WhispAssist_<version>_x64-setup.exe`** from the same page — it
lets you choose a **current-user** (no admin) or **all-users** install.
**Deploying to many machines?** See [`docs/enterprise-deployment.md`](docs/enterprise-deployment.md)
for silent install, custom install location, and presetting defaults with a `wa-defaults.ini` file.
## Optional dependencies ## Optional dependencies
@@ -155,7 +160,7 @@ If you do not have these installed, WhispAssist will still work, but some featur
WhispAssist is a **Tauri 2** application: a small Rust core with a compiled **Svelte + TypeScript** WhispAssist is a **Tauri 2** application: a small Rust core with a compiled **Svelte + TypeScript**
frontend rendered through the OS WebView2 (no bundled browser → low idle memory). Every decision frontend rendered through the OS WebView2 (no bundled browser → low idle memory). Every decision
and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0011). and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0012).
- **Shell / IPC:** Tauri 2 (Rust ⇄ WebView2) - **Shell / IPC:** Tauri 2 (Rust ⇄ WebView2)
- **Audio capture:** WASAPI loopback - **Audio capture:** WASAPI loopback
@@ -176,7 +181,7 @@ and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0011).
WhispAssist/ WhispAssist/
├── docs/ # The engineering plan (read this first) ├── docs/ # The engineering plan (read this first)
│ ├── 00-overview.md … 07-research-findings.md │ ├── 00-overview.md … 07-research-findings.md
│ └── adr/ Architecture Decision Records (0001–0011) │ └── adr/ Architecture Decision Records (0001–0012)
├── src-tauri/ # Rust core — implemented service modules: ├── src-tauri/ # Rust core — implemented service modules:
│ └── src/{audio,transcription,diarization,storage,llm,calendar, │ └── src/{audio,transcription,diarization,storage,llm,calendar,
│ hardware,notes,sync,mcp,vault} │ hardware,notes,sync,mcp,vault}
-96
View File
@@ -1,96 +0,0 @@
# WhispAssist v0.5.0
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
This release is about **living with your recordings**: play back a meeting while following along in
the transcript, manage action items by hand, move recordings between computers, drop a meeting into
your Obsidian vault, and — new in this release — **use WhispAssist in your own language**. Everything
stays off-by-default and local-first.
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
---
## ✨ Highlights
### Click the transcript to play that moment
The recording player and the transcript now talk to each other. **Click any transcript line to jump
the audio to that moment** and start playing, and as playback runs the **current line highlights and
scrolls into view** so you never lose your place. If you scroll by hand, auto-scroll steps aside for
a few seconds so it doesn't fight you.
### Interface language selector (i18n)
WhispAssist can now be **fully translated**. A new **Settings ▸ Language** picker switches the
interface language, English ships as the baseline, and **every** user-facing string across the app —
the shell, meetings list, transcript/notes, summary panel, and all of Settings — now flows through a
single translation layer. Adding a new language is as simple as translating **one JSON file**; no
code changes. (This release ships English; the groundwork is in place for community translations.)
### Manage action items yourself
Action items are no longer just whatever the summary extracted. You can now **add, edit, and delete
them by hand** in the summary panel, set an owner and due date, and toggle a local reminder. Your
edits are the source of truth and are reconciled cleanly — deleting an item also cancels its reminder.
### Move recordings between computers (Export / Import)
A new **Export & import** section in **Settings ▸ Storage** writes each meeting as a portable
**bundle folder** (audio, transcript, notes, summary, and a `meeting.json` manifest) and imports them
back on another machine. Imported meetings get a fresh id, so re-importing never overwrites anything.
Export to any folder — a synced drive, a USB stick, or a sync target's local mount — and carry it across.
### Export a meeting to Obsidian
A new **Obsidian** export writes a single self-contained vault note — YAML frontmatter
(title, date, duration, participants, tags) plus notes, summary, action items, and a timestamped
transcript — **without the audio**. Drop it in your vault and the transcript is fully searchable.
---
## 🚀 Also new since v0.4.0
- **Per-segment transcript timestamps** — every line now shows a quiet `m:ss` (or `h:mm:ss`) time
prefix, in both the live and finalized views.
- **Auto-resync on edit** — when sync is enabled, editing a meeting's notes, summary, transcript,
tags, or action items **re-uploads just the changed artifacts** automatically (deduped by hash, so
an unchanged save uploads nothing). Off unless sync is configured.
## 🐛 Fixes & polish
- Transcript scroll-intent handling refined so playback auto-scroll never yanks you back while you're
reading.
- Import preserves each meeting's original date rather than stamping the import time.
---
## 📦 Install
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
1. Download **`WhispAssist_0.5.0_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.5.0_x64-setup.exe`**).
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
3. Launch **WhispAssist** from the Start menu.
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
without admin rights, never adds itself to startup, and keeps all data under
`%LOCALAPPDATA%\WhispAssist`.
## 🔐 Checksums (SHA-256)
```
b423feed1a1171384e46c5e0b5aa63cbf912bfe8f29cc89a5a027bb15358075c WhispAssist_0.5.0_x64_en-US.msi
2dce26b5603a5d02094533c594d814a3d72e05512e1d735ea69e1e309f15b710 WhispAssist_0.5.0_x64-setup.exe
```
Verify after download:
```powershell
Get-FileHash .\WhispAssist_0.5.0_x64_en-US.msi -Algorithm SHA256
```
---
## Privacy, unchanged
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
MCP server is loopback-only and adds no egress. The reachable-host allowlist is derived from your
settings and enforced in the core.
-73
View File
@@ -1,73 +0,0 @@
# WhispAssist v0.5.1
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
v0.5.0 put the **i18n groundwork** in place. v0.5.1 fills it in: WhispAssist now ships in
**20 languages**, so you can run the whole app — the shell, meetings list, transcript/notes, summary
panel, and every corner of Settings — in your own language. Everything stays off-by-default and
local-first; this is a UI-language release with no change to what leaves your device (nothing, by default).
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
---
## ✨ Highlights
### 20 interface languages
Pick your language under **Settings ▸ Language**. Alongside English, this release adds full
translations for:
- **Arabic** (العربية) · **Bengali** (বাংলা) · **German** (Deutsch)
- **Spanish** — Spain (Español, España) and **Mexico** (Español, México)
- **Finnish** (Suomi) · **French** — France (Français, France) and **Canada** (Français, Canada)
- **Hindi** (हिन्दी) · **Korean** (한국어) · **Norwegian Bokmål** (Norsk bokmål)
- **Portuguese** — Brazil (Português, Brasil) and **Portugal** (Português, Portugal)
- **Russian** (Русский) · **Sinhala** (සිංහල) · **Swedish** (Svenska)
- **Tamil** (தமிழ்) · **Urdu** (اردو) · **Mandarin Chinese, Simplified** (中文简体)
Your choice persists across launches, and any untranslated string quietly falls back to English rather
than showing a raw key — so partial translations degrade gracefully.
### Right-to-left layout
Selecting **Arabic** or **Urdu** flips the whole interface to **right-to-left**, so those languages
read and lay out correctly rather than being crammed into an LTR shell.
Adding a further language remains a one-file job — drop in a single JSON translation, no code changes.
---
## 📦 Install
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
1. Download **`WhispAssist_0.5.1_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.5.1_x64-setup.exe`**).
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
3. Launch **WhispAssist** from the Start menu.
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
without admin rights, never adds itself to startup, and keeps all data under
`%LOCALAPPDATA%\WhispAssist`.
## 🔐 Checksums (SHA-256)
```
911618e6996a079bad1cbe46731c89ee247f3f5f17c09b60091d7e6a5e7cbef0 WhispAssist_0.5.1_x64_en-US.msi
ffabbd74def981da176931349b2813a3927d7376c0d18e9d191891abfc17c385 WhispAssist_0.5.1_x64-setup.exe
```
Verify after download:
```powershell
Get-FileHash .\WhispAssist_0.5.1_x64_en-US.msi -Algorithm SHA256
```
---
## Privacy, unchanged
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
MCP server is loopback-only and adds no egress. The reachable-host allowlist is derived from your
settings and enforced in the core.
-64
View File
@@ -1,64 +0,0 @@
# WhispAssist v0.5.2
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
A small but important bug-fix release. v0.5.2 fixes a startup crash that could stop WhispAssist
from opening after an upgrade, and makes any future database problem show a clear message instead
of failing silently. No feature or privacy changes — everything optional stays off-by-default and
local-first.
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
---
## 🐛 Fixes
### Fixed: app failing to launch after an upgrade
On some machines, upgrading could leave the app unable to open at all — no window, no message,
just nothing. The cause was a database-migration mismatch during startup: WhispAssist would hit
the error while opening its local database (`wa.db`) and, because the failure happened before the
window existed, the process exited silently and Windows reported only a generic crash.
### Startup database errors now explain themselves
Instead of that silent exit, a failure to open `wa.db` now shows a **native error dialog** naming
the problem and pointing at the database location, then exits cleanly. Your recordings and notes
are never touched — the message tells you exactly what happened and how to recover, rather than
leaving you with an app that won't start.
---
## 📦 Install
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
1. Download **`WhispAssist_0.5.2_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.5.2_x64-setup.exe`**).
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
3. Launch **WhispAssist** from the Start menu.
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
without admin rights, never adds itself to startup, and keeps all data under
`%LOCALAPPDATA%\WhispAssist`.
## 🔐 Checksums (SHA-256)
```
b173b6b15590a65e96d3b24d0ad97666b8d1da905169d807ecc1c51df0584d3e WhispAssist_0.5.2_x64_en-US.msi
441b2e7ec7c68be39f9d2381af59ab9eadf916d6d23d0c5541d78bdd88c588b2 WhispAssist_0.5.2_x64-setup.exe
```
Verify after download:
```powershell
Get-FileHash .\WhispAssist_0.5.2_x64_en-US.msi -Algorithm SHA256
```
---
## Privacy, unchanged
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
MCP server is loopback-only and adds no egress. The reachable-host allowlist is derived from your
settings and enforced in the core.
+78
View File
@@ -0,0 +1,78 @@
# WhispAssist v0.7.0
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
This release is about **control over install and startup** — for a single user and for admins rolling
WhispAssist out across a fleet. You can now launch WhispAssist at login, choose a per-user (no-admin)
or all-users install, preset every default with a deployment file, and re-scan your hardware without
restarting. No feature here changes the privacy posture: everything optional stays off-by-default and
local-first.
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
---
## ✨ New
### Launch at login (opt-in)
A new **Settings ▸ Recording ▸ Launch WhispAssist at login** toggle starts WhispAssist when you sign
in to Windows. It's **off by default**, needs **no admin** (a per-user startup entry), and does **not**
begin recording on its own.
### Choose a per-user or all-users install
The NSIS setup (`WhispAssist_0.7.0_x64-setup.exe`) now lets you install for **just yourself**
(no admin rights required) or for **all users**. The MSI remains the per-machine, all-users installer.
### Customize deployments with a file (`wa-defaults.ini`)
Admins can preset WhispAssist's defaults for every machine using native Windows tooling (Group Policy,
SCCM, Intune, `msiexec`) — no management console. Drop a `wa-defaults.ini` next to the installer or in
`%PROGRAMDATA%\WhispAssist\`, and each machine's **first launch** seeds its settings from it: record-
by-default, preferred backend, a model to auto-download, retention, storage location, AI provider, and
more. You can also set a **custom install location** with standard `msiexec INSTALLDIR=…` / NSIS `/D=`
flags. **Secrets are never read from this file** — API keys and tokens stay in the OS credential store.
Full key reference and silent-install examples in
[`docs/enterprise-deployment.md`](docs/enterprise-deployment.md).
### Refresh hardware
A **Refresh** button in **Settings ▸ Hardware** re-detects your GPU/NPU on the spot — handy after
installing a driver or plugging in an eGPU — no restart needed.
---
## 📦 Install
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
(and **`yt-dlp`** for URLs) on your PATH — install those separately.
1. Download **`WhispAssist_0.7.0_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.7.0_x64-setup.exe`**).
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
3. Launch **WhispAssist** from the Start menu.
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
without admin rights, does **not** add itself to startup unless you opt in, and keeps all data under
`%LOCALAPPDATA%\WhispAssist`.
Deploying to many machines? See [`docs/enterprise-deployment.md`](docs/enterprise-deployment.md).
## 🔐 Checksums (SHA-256)
```
730676580dbef5c4bb2c155f46a73a5791722139ab144a3fc0286e3a4449c432 WhispAssist_0.7.0_x64_en-US.msi
6a34c16eb6f876fc92c7a79d37414b4fdd2c09f4f40873008149db36cd0a30aa WhispAssist_0.7.0_x64-setup.exe
```
Verify after download:
```powershell
Get-FileHash .\WhispAssist_0.7.0_x64_en-US.msi -Algorithm SHA256
```
---
## Privacy, unchanged
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
MCP server is loopback-only and adds no egress; the deployment file never carries secrets. The
reachable-host allowlist is derived from your settings and enforced in the core.
+94
View File
@@ -0,0 +1,94 @@
# WhispAssist v0.7.1
**Privacy-first, Windows-native meeting assistant — everything on-device, nothing leaves unless you say so.**
A quality-of-life release focused on **importing meetings, taking notes, and living in the
background**. Adding a meeting from a file or link now runs without freezing the app and shows you
exactly where it's up to; notes gained AI cleanup and quick formatting; and WhispAssist can now mute
your mic mid-meeting and tuck itself into the system tray. No feature here changes the privacy
posture: everything optional stays off-by-default and local-first.
One universal installer (**MSI** and **NSIS**) covers every machine: **Vulkan** for all GPUs
(NVIDIA/AMD/Intel), the Intel **NPU** (OpenVINO), a **DirectML** fallback, and **CPU**.
---
## ✨ New
### Add a meeting — now in the background, with a progress tracker
Importing a recording (a local audio/video file or a YouTube/streaming/direct URL) no longer blocks
the app while it works. Click **Import** and the meeting appears in your list immediately with a
**four-step progress tracker** — *Transcode → Transcribe → Identify speakers → Finalize* — where the
current step pulses and finished steps show how long they took. A 25-minute video that used to lock
the window for ~13 minutes now transcribes quietly in the background.
- **Pick the transcription model** right in the dialog, and see **"Transcribed with …"** on the
finished meeting so you always know how it was produced.
- **One-click links** to download `ffmpeg` and `yt-dlp` (still external, not bundled).
- A failed import stays in your list marked **error** instead of vanishing.
### Better notes
- **AI-enhance** (✨): turn rough notes into clean, structured notes using your local LLM, with a
one-step **Undo**. Off unless you have a local model configured.
- **Slash commands & a formatting toolbar**: type `/todo`, `/h1`, `/quote`, … or use the toolbar for
headings, lists, checkboxes, quotes, and dividers.
- **Fix:** notes no longer show stale text after re-transcribing a meeting — the pane updates in
place, no restart needed.
### Mute your microphone — press **M**
Mute/unmute the mic mid-meeting with the **M** key or the new mic button by the level meter. The mic
channel goes silent (recording, live transcript, and meter) while system/loopback audio keeps
capturing.
### Close to system tray
Closing the window now **keeps WhispAssist running in the background** instead of quitting. Reopen it
from the tray icon; the tray's **Quit** exits fully. On by default — toggle it in
**Settings ▸ Recording ▸ Close to system tray**. (This release also fixes a bug that showed **two**
WhispAssist icons in the tray — there's now just one.)
### Privacy & hardware odds and ends
- **Vault lock card** in **Settings ▸ Privacy**: lock/unlock the encrypted store and change its
password at a glance.
- **Test your audio devices**: a live level meter for your mic and system audio, plus a test tone.
- **Quick hardware stress test**: benchmark the available backends against your installed models and
apply the fastest real-time combination.
- Tidier recording header.
---
## 📦 Install
**Requirements:** Windows 10 or 11 (x64). WhispAssist needs **WebView2** (preinstalled on Windows 11;
the installer fetches it on Windows 10). Importing from a file/URL additionally needs **`ffmpeg`**
(and **`yt-dlp`** for URLs) on your PATH — the Import dialog now links to both.
1. Download **`WhispAssist_0.7.1_x64_en-US.msi`** (or the NSIS **`WhispAssist_0.7.1_x64-setup.exe`**).
2. Run it and accept the UAC prompt. If SmartScreen appears, choose **More info → Run anyway**.
3. Launch **WhispAssist** from the Start menu.
On first run WA picks the best transcription backend (**NPU → NVIDIA → AMD → Intel → CPU**). It runs
without admin rights, does **not** add itself to startup unless you opt in, and keeps all data under
`%LOCALAPPDATA%\WhispAssist`.
Deploying to many machines? See [`docs/enterprise-deployment.md`](docs/enterprise-deployment.md).
## 🔐 Checksums (SHA-256)
```
bd96a059db3658a9bee81161edc709cfc4741ed99d6c66a12194403b339f9369 WhispAssist_0.7.1_x64_en-US.msi
711fa7df618c8fc3f03c18d543abad2288d56f750b532288204e0ec0f9426595 WhispAssist_0.7.1_x64-setup.exe
```
Verify after download:
```powershell
Get-FileHash .\WhispAssist_0.7.1_x64_en-US.msi -Algorithm SHA256
```
---
## Privacy, unchanged
Everything optional is **off by default**. With nothing configured, WhispAssist makes **no content
egress at all**. Recording is opt-in; sync/AI credentials live only in the OS credential store; the
MCP server is loopback-only and adds no egress; the deployment file never carries secrets. The
reachable-host allowlist is derived from your settings and enforced in the core.
+21
View File
@@ -382,6 +382,13 @@ segment (the M1 grounding invariant, asserted by the golden-transcript test).
"expose_recordings": false, // never serve .wav unless explicitly true (FR-MCP-3) "expose_recordings": false, // never serve .wav unless explicitly true (FR-MCP-3)
}, },
"privacy": { "encrypt_at_rest": false }, "privacy": { "encrypt_at_rest": false },
// Launch WhispAssist at login (NFR-RES-4). Opt-in, OFF by default. Toggling this
// via `set_auto_start` also writes a per-user `HKCU\...\Run` entry (no admin);
// startup reconciles the OS entry to this flag (e.g. after a reinstall).
"auto_start": false,
// Closing the window hides WhispAssist to the system tray (keep running in background) instead of
// quitting; ON by default. Tray "Quit" is the real exit. Enforced in the Rust on_window_event handler.
"close_to_tray": true,
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6). Opt-in, explicit consent via OAuth // Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6). Opt-in, explicit consent via OAuth
// PKCE — OFF by default. `credential_ref` points into the OS credential store; the token itself // PKCE — OFF by default. `credential_ref` points into the OS credential store; the token itself
// is never written here (same invariant as sync credentials, FR-SYNC-6). // is never written here (same invariant as sync credentials, FR-SYNC-6).
@@ -389,6 +396,20 @@ segment (the M1 grounding invariant, asserted by the golden-transcript test).
} }
``` ```
## First-run deploy seeding (`wa-defaults.ini`)
For enterprise mass-deployment, the **first** launch on a machine (before `settings.json` exists)
optionally seeds its defaults from an admin-supplied INI. First file found wins:
1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide (GPO / SCCM / Intune file copy).
2. `<install dir>\wa-defaults.ini` — the bundled template (shipped fully commented → no-op).
Keys are flat `key = value` matching `settings.json` field names (bools/ints coerced), plus the
special `auto_download_model = true` which fetches the configured `whisper_model` in the background.
**No secrets** — any key containing `key`/`token`/`secret`/`credential`/`password` is ignored; those
live only in the OS credential store. After first run the file is never read again. See
`docs/enterprise-deployment.md` and `src-tauri/src/deploy.rs`.
## Retention & recovery semantics ## Retention & recovery semantics
- **Retention** (FR-STORE-2): a background job deletes whole meeting folders + rows once a meeting - **Retention** (FR-STORE-2): a background job deletes whole meeting folders + rows once a meeting
+23 -1
View File
@@ -23,6 +23,9 @@ start_recording(input: { meetingTitle?: string; calendarEventId?: string; record
stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef
pause_recording(input: { meetingId: MeetingId }): void pause_recording(input: { meetingId: MeetingId }): void
resume_recording(input: { meetingId: MeetingId }): void resume_recording(input: { meetingId: MeetingId }): void
// Mute/unmute the mic mid-meeting (FR-CAP-7): mic channel goes silent (recording + transcript + meter),
// loopback keeps capturing. Returns the new muted state; emits recording://mic. Errs if the mic is off.
toggle_microphone_mute(input: { meetingId: MeetingId }): boolean
set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1) set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1)
acknowledge_recording_consent(): void // one-time (FR-REC-2) acknowledge_recording_consent(): void // one-time (FR-REC-2)
@@ -36,8 +39,14 @@ update_live_notes(input: { meetingId: MeetingId; markdown: string }): void
set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void
// ---- Hardware ---- // ---- Hardware ----
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number } hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number } // re-detects fresh each call — backs the Settings ▸ Hardware "Refresh" button
set_preferred_backend(input: { backend: BackendId | "auto" }): void set_preferred_backend(input: { backend: BackendId | "auto" }): void
// Launch-at-login (NFR-RES-4). Writes/removes a per-user OS Run entry (no admin) and persists auto_start. Opt-in, off by default.
set_auto_start(input: { enabled: boolean }): void
// Device test: opens a mic ("input") or the render device in loopback ("loopback") for a few seconds and streams device://level (no recording, no retained audio). Refused while recording.
monitor_audio_level(input: { kind: "input" | "loopback"; deviceId?: string; durationMs?: number }): void
// Quick stress test: benchmarks each available backend × installed model (≤3 sizes) on a fixed sample, returns per-pair real-time factor + the most-accurate real-time-capable recommendation. Emits stress://progress. Refused while recording.
stress_test_hardware(): { results: { backend: string; model: string; rtf: number; realtime: boolean }[]; recommended: { backend: string; model: string } | null }
// ---- Transcription / models ---- // ---- Transcription / models ----
// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it. // language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it.
@@ -79,6 +88,8 @@ export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf"
// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an // they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an
// edit that didn't alter a file uploads nothing. // edit that didn't alter a file uploads nothing.
update_notes(input: { meetingId: MeetingId; markdown: string }): void update_notes(input: { meetingId: MeetingId; markdown: string }): void
// AI-enhance rough notes into structured Markdown grounded in the transcript (Granola-style), via the configured LlmProvider (no new egress). Takes the live buffer, returns the enhanced text WITHOUT persisting — the UI keeps or undoes it. Refused while recording; errors with no provider.
enhance_notes(input: { meetingId: MeetingId; notes: string }): string
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string // SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1) search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
set_tags(input: { meetingId: MeetingId; tags: string[] }): void set_tags(input: { meetingId: MeetingId; tags: string[] }): void
@@ -93,6 +104,13 @@ bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "
// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original // folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original
// title/date/duration/speakers/tags/action items preserved). Returns the count imported. // title/date/duration/speakers/tags/action items preserved). Returns the count imported.
import_meeting_bundle(input: { dir: string }): number import_meeting_bundle(input: { dir: string }): number
// Add a meeting from an existing recording: a local audio/video file path or a URL (YouTube/
// streaming page or direct media URL). Needs ffmpeg (+ yt-dlp for URLs) on PATH; neither bundled.
// `model` overrides the Settings whisper model for this import (recorded as meeting.model_used).
// Returns the new meeting id IMMEDIATELY (status "transcribing"); transcode→transcribe→diarize→
// finalize run in the background, streaming import://progress and ending with transcript://finalized.
// A failed import is left in the list with status "error" (not deleted).
import_media(input: { source: string; title?: string; model?: string }): MeetingId
// ---- LLM / AI provider (ADR-0007/0011) ---- // ---- LLM / AI provider (ADR-0007/0011) ----
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to // provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
@@ -183,8 +201,10 @@ privacy_self_check(): {
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs } "recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5) "recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6) "recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
"recording://mic" { meetingId, muted: boolean } // mic mute toggled (FR-CAP-7)
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2); may re-emit a committed segment with a refined `speaker` — replace by `segment.id` "transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2); may re-emit a committed segment with a refined `speaker` — replace by `segment.id`
"transcript://finalized" { meetingId, segmentCount } "transcript://finalized" { meetingId, segmentCount }
"import://progress" { meetingId, phase: "prepare"|"transcribe"|"diarize"|"finalize", state: "active"|"done"|"error", elapsedMs: number|null, error: string|null } // background import_media tracker
"diarization://updated" { meetingId, speakers: SpeakerInfo[] } // post-pass AND live 15s provisional passes (FR-SPK); carries "You" once the mic voiceprint matches "diarization://updated" { meetingId, speakers: SpeakerInfo[] } // post-pass AND live 15s provisional passes (FR-SPK); carries "You" once the mic voiceprint matches
"llm://token" { meetingId, text } // streamed summary (FR-LLM-4) "llm://token" { meetingId, text } // streamed summary (FR-LLM-4)
"llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer "llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer
@@ -193,6 +213,8 @@ privacy_self_check(): {
"calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4) "calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4)
"calendar://progress" { processed, total } // MS Graph import (M4.4) "calendar://progress" { processed, total } // MS Graph import (M4.4)
"hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4) "hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4)
"device://level" { kind: "input"|"loopback", rms?, peak?, done?: boolean } // Settings device test meter; done=window ended
"stress://progress" { backend: string, model: string } // quick stress test, per pairing benchmarked
"recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3) "recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3)
"sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5 "sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5
"sync://done" { meetingId, targetId, uploaded: number, failed: number } "sync://done" { meetingId, targetId, uploaded: number, failed: number }
@@ -0,0 +1,54 @@
# ADR-0012 — Launch-at-login & enterprise deployment defaults
- **Status:** Accepted
- **Date:** 2026-07-14
- **Context source:** User request (2026-07-14) — auto-start on boot; customize an installation
(install location, per-user/all-users, default settings) with native Windows tooling for
mass-deployment.
## Context
Two related needs. (1) Users want WhispAssist to **launch automatically at login**. NFR-RES-4
forbids adding WA to OS startup without explicit consent, so this must be opt-in. (2) An admin
mass-deploying WA to many machines wants to **customize the deployment** — install location, whether
it installs per-user (no admin) or all-users, and the app's default settings (record-by-default,
preferred backend, a model to pre-download, retention, AI provider) — using **native Windows tools**
(GPO / SCCM / Intune / `msiexec` / silent NSIS), not a bespoke management console.
## Decision
1. **Launch-at-login is opt-in, off by default.** A `set_auto_start` command uses
`tauri-plugin-autostart` to write a **per-user** `HKCU\...\Run` entry (no admin) and persists an
`auto_start` setting. Startup reconciles the OS entry to that flag (restores it after a reinstall).
Nothing runs on a timer — this is a registry entry, not a background process (NFR-RES-1).
2. **Install location & scope are native, no app code.**
- Location: `msiexec INSTALLDIR=…` (MSI) / NSIS `/D=…` (silent).
- Scope: NSIS `installMode: "both"` — the `.exe` setup lets the user choose **current-user
(no admin)** or **all-users (admin)**. The MSI stays per-machine as the enterprise all-users
artifact.
3. **Default settings via a first-run `wa-defaults.ini`.** On a machine's **first** launch (before
`settings.json` exists) WA reads an admin-supplied INI — `%PROGRAMDATA%\WhispAssist\wa-defaults.ini`
first, else the bundled `<install dir>\wa-defaults.ini` — and seeds `settings.json` from it, with an
optional `auto_download_model` to pre-fetch the model. The shipped template is fully commented, so
a normal install is unaffected. This is deployable purely by copying a file with existing Windows
management tooling; no WiX custom actions.
## Consequences
- **Positive:** opt-in startup honors NFR-RES-4 with zero idle cost; install location/scope reuse the
installers' native behavior (no custom code to maintain); one small INI + a first-run guard covers
the whole deployment-customization surface and works for MSI, NSIS, and portable copies alike.
- **Guardrail — no secrets in the deploy file (CLAUDE.md):** the INI must never carry credentials.
`deploy.rs` drops any key containing `key`/`token`/`secret`/`credential`/`password` as defense in
depth; API keys, OAuth tokens and sync passwords remain in the OS credential store only. Seeding a
provider (e.g. `llm_provider=anthropic`) still requires the admin/user to provision its key
separately — no new egress path is created by the file.
- **Negative / care:** the seed runs only when `settings.json` is absent (truly first run); it does
**not** re-apply on upgrade, matching "the user's own settings win thereafter." Array config merges
in Tauri **replace** rather than append, so `wa-defaults.ini` must be listed in both
`tauri.conf.json` and `tauri.vulkan.conf.json` bundle resources (the shipped build uses the latter).
## Revisit if
Admins need per-machine policy that **overrides** user settings on every launch (not just seeds
first-run), or a signed/locked-down enterprise policy channel beyond a plain INI.
+101
View File
@@ -0,0 +1,101 @@
# Enterprise deployment
How to mass-deploy WhispAssist and preset its defaults with native Windows tooling (Group Policy,
SCCM, Intune, `msiexec`, silent NSIS). No management console, no phone-home. See ADR-0012.
WhispAssist ships two bundles:
| Bundle | Scope | Admin? |
|---|---|---|
| `WhispAssist_<ver>_x64_en-US.msi` | Per-machine (all users) | Yes |
| `WhispAssist_<ver>_x64-setup.exe` (NSIS) | Current-user **or** all-users (prompts) | Only for all-users |
## Install location
- **MSI:** `msiexec /i WhispAssist_<ver>_x64_en-US.msi INSTALLDIR="D:\Apps\WhispAssist" /qn`
(`INSTALLDIR` is Tauri's WiX install-dir property; confirm against the generated `.wxs` if a build
changes it.)
- **NSIS:** `WhispAssist_<ver>_x64-setup.exe /S /D=D:\Apps\WhispAssist`
(`/S` = silent, `/D=` = install dir; `/D=` must be **last** and unquoted per NSIS.)
## Install scope (per-user vs all-users)
The NSIS `.exe` shows a "current user / all users" page. **Current user needs no admin** and installs
under the user profile; **all users** requires elevation. Silent all-users:
`WhispAssist_<ver>_x64-setup.exe /S`. The MSI is always per-machine (all-users) and requires admin.
## Auto-start at login
Off by default (NFR-RES-4). Turn it on for the user either in-app (Settings ▸ Recording ▸ *Launch
WhispAssist at login*) or by presetting `auto_start = true` in `wa-defaults.ini` (below). It installs
a **per-user** `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry — no admin, and it does
**not** start recording on its own.
## Preset default settings — `wa-defaults.ini`
On a machine's **first** launch (before `settings.json` exists), WhispAssist reads an admin-supplied
INI and seeds that user's `settings.json`. After that the user's own settings win and the file is
ignored. First location found wins:
1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide. Deploy with a GPO/SCCM/Intune file copy.
2. `<install dir>\wa-defaults.ini` — the template shipped next to the executable.
The shipped template is fully commented out, so a default install behaves as if it were absent.
Uncomment and edit the keys you want to preset.
### Format
Flat `key = value`, one per line. `;` and `#` comment lines and `[section]` headers are ignored.
`true`/`false` become switches, plain numbers become numbers, everything else is text. Unknown or
misspelled keys are ignored.
> **Never put secrets in this file.** API keys, OAuth tokens and sync passwords live only in the OS
> credential store. Any key containing `key`, `token`, `secret`, `credential` or `password` is
> dropped on read. Presetting `llm_provider = anthropic` still requires the key to be provisioned
> separately — the file adds no egress path.
### Keys
| Key | Values | Meaning |
|---|---|---|
| `default_record` | true/false | Record every meeting by default (consent notice still applies). |
| `preferred_backend` | auto\|npu\|nvidia\|amd\|intel\|cpu | Transcription backend. |
| `whisper_model` | catalog id (e.g. `base.en-q5_1`) | Default transcription model. |
| `auto_download_model` | true/false | Fetch `whisper_model` in the background on first launch. |
| `whisper_language` | auto\|ISO-639-1 | Default language (multilingual model only). |
| `low_overhead` | true/false | CPU + smallest model preset. |
| `storage_root` | path | Where meetings are stored. |
| `retention_max_age_days` | number | Delete meetings older than N days. |
| `retention_max_size_gb` | number | Cap total storage at N GB. |
| `llm_provider` | ollama\|custom\|anthropic\|off | Summary provider (key provisioned separately). |
| `llm_endpoint` | url | LLM endpoint. |
| `llm_model` | text | LLM model name. |
| `microphone_enabled` | true/false | Capture the user's mic into the transcript. |
| `auto_record_calendar` | true/false | Auto-start recording on calendar events (app open only). |
| `theme` | system\|light\|dark | UI theme. |
| `auto_start` | true/false | Launch WhispAssist at login (per-user Run entry). |
| `sync_enabled` | true/false | Sync master switch (targets/credentials configured in-app). |
### Example
```ini
default_record = true
preferred_backend = npu
whisper_model = small.en-q5_1
auto_download_model = true
retention_max_age_days = 90
auto_start = true
```
## Silent end-to-end example
```bat
:: 1. Push machine-wide defaults (as SYSTEM via GPO/SCCM)
mkdir "%ProgramData%\WhispAssist"
copy wa-defaults.ini "%ProgramData%\WhispAssist\wa-defaults.ini"
:: 2. Install per-machine, custom location, no UI
msiexec /i WhispAssist_<ver>_x64_en-US.msi INSTALLDIR="C:\Program Files\WhispAssist" /qn
```
Each user's first launch then seeds their `settings.json` from the machine-wide file.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "whispassist", "name": "whispassist",
"private": true, "private": true,
"version": "0.6.0", "version": "0.7.2",
"type": "module", "type": "module",
"description": "Privacy-first, fully local Windows meeting assistant.", "description": "Privacy-first, fully local Windows meeting assistant.",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
+57 -2
View File
@@ -124,6 +124,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -848,6 +859,15 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "5.0.1" version = "5.0.1"
@@ -866,6 +886,17 @@ dependencies = [
"dirs-sys 0.5.0", "dirs-sys 0.5.0",
] ]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]] [[package]]
name = "dirs-sys" name = "dirs-sys"
version = "0.4.1" version = "0.4.1"
@@ -1043,7 +1074,7 @@ dependencies = [
"rustc_version", "rustc_version",
"toml 1.1.2+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg 0.55.0",
] ]
[[package]] [[package]]
@@ -4935,6 +4966,20 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-autostart"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.7.1" version = "2.7.1"
@@ -6043,7 +6088,7 @@ dependencies = [
[[package]] [[package]]
name = "whispassist" name = "whispassist"
version = "0.6.0" version = "0.7.2"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
@@ -6073,6 +6118,7 @@ dependencies = [
"sqlx", "sqlx",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
@@ -6767,6 +6813,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.55.0" version = "0.55.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "whispassist" name = "whispassist"
version = "0.6.0" version = "0.7.2"
description = "Privacy-first, fully local Windows meeting assistant" description = "Privacy-first, fully local Windows meeting assistant"
authors = ["WhispAssist contributors"] authors = ["WhispAssist contributors"]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -81,6 +81,7 @@ ort = { version = "=2.0.0-rc.10", optional = true, default-features = false, fea
rustfft = { version = "6", optional = true } rustfft = { version = "6", optional = true }
sherpa-rs = { version = "0.6", optional = true, default-features = false, features = ["download-binaries"] } # sherpa-onnx bindings (Phase 4, ADR-0005) sherpa-rs = { version = "0.6", optional = true, default-features = false, features = ["download-binaries"] } # sherpa-onnx bindings (Phase 4, ADR-0005)
tauri-plugin-dialog = "2" # native Save/choose-folder (Phase 2 export) tauri-plugin-dialog = "2" # native Save/choose-folder (Phase 2 export)
tauri-plugin-autostart = "2" # opt-in launch-on-login (per-user HKCU\Run, no admin; NFR-RES-4)
# notes export (Phase 8, FR-NOTE-4) — pure-Rust, no external binary/cloud # notes export (Phase 8, FR-NOTE-4) — pure-Rust, no external binary/cloud
# conversion service, consistent with the fully-local invariant. # conversion service, consistent with the fully-local invariant.
File diff suppressed because one or more lines are too long
+42
View File
@@ -176,6 +176,48 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
"type": "string",
"const": "autostart:default",
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
},
{
"description": "Enables the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-disable",
"markdownDescription": "Enables the disable command without any pre-configured scope."
},
{
"description": "Enables the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-enable",
"markdownDescription": "Enables the enable command without any pre-configured scope."
},
{
"description": "Enables the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-is-enabled",
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
},
{
"description": "Denies the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-disable",
"markdownDescription": "Denies the disable command without any pre-configured scope."
},
{
"description": "Denies the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-enable",
"markdownDescription": "Denies the enable command without any pre-configured scope."
},
{
"description": "Denies the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-is-enabled",
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
},
{ {
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string", "type": "string",
+42
View File
@@ -176,6 +176,48 @@
"Identifier": { "Identifier": {
"description": "Permission identifier", "description": "Permission identifier",
"oneOf": [ "oneOf": [
{
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
"type": "string",
"const": "autostart:default",
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
},
{
"description": "Enables the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-disable",
"markdownDescription": "Enables the disable command without any pre-configured scope."
},
{
"description": "Enables the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-enable",
"markdownDescription": "Enables the enable command without any pre-configured scope."
},
{
"description": "Enables the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:allow-is-enabled",
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
},
{
"description": "Denies the disable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-disable",
"markdownDescription": "Denies the disable command without any pre-configured scope."
},
{
"description": "Denies the enable command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-enable",
"markdownDescription": "Denies the enable command without any pre-configured scope."
},
{
"description": "Denies the is_enabled command without any pre-configured scope.",
"type": "string",
"const": "autostart:deny-is-enabled",
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
},
{ {
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
"type": "string", "type": "string",
+32 -1
View File
@@ -45,9 +45,26 @@ pub enum AudioError {
pub struct CaptureHandle { pub struct CaptureHandle {
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
paused: Arc<AtomicBool>, paused: Arc<AtomicBool>,
/// Mic-only (FR-CAP-7): when set, the microphone stream emits silence instead
/// of real samples — the recording's mic-left channel and the live transcript
/// go quiet, the meter drops to zero, while loopback keeps recording. Toggled
/// live via `set_muted` (the "press M to mute" control).
muted: Arc<AtomicBool>,
thread: JoinHandle<Result<CaptureSummary, AudioError>>, thread: JoinHandle<Result<CaptureSummary, AudioError>>,
} }
impl CaptureHandle {
/// Mute/unmute this stream live. Only meaningful for the microphone capture.
pub fn set_muted(&self, muted: bool) {
self.muted.store(muted, Ordering::SeqCst);
}
/// Whether this stream is currently muted.
pub fn is_muted(&self) -> bool {
self.muted.load(Ordering::SeqCst)
}
}
/// Where captured frames are delivered for live transcription: mono f32 @ 16kHz, /// Where captured frames are delivered for live transcription: mono f32 @ 16kHz,
/// bounded so a slow/absent consumer can never stall the capture thread. /// bounded so a slow/absent consumer can never stall the capture thread.
pub type FrameSink = SyncSender<Vec<f32>>; pub type FrameSink = SyncSender<Vec<f32>>;
@@ -317,8 +334,10 @@ impl WasapiCapture {
) -> Result<CaptureHandle, AudioError> { ) -> Result<CaptureHandle, AudioError> {
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let paused = Arc::new(AtomicBool::new(false)); let paused = Arc::new(AtomicBool::new(false));
let muted = Arc::new(AtomicBool::new(false));
let running_th = running.clone(); let running_th = running.clone();
let paused_th = paused.clone(); let paused_th = paused.clone();
let muted_th = muted.clone();
let wav_path = wav_path.map(Path::to_path_buf); let wav_path = wav_path.map(Path::to_path_buf);
let device_id = device_id.map(str::to_string); let device_id = device_id.map(str::to_string);
@@ -333,6 +352,7 @@ impl WasapiCapture {
&event_sink, &event_sink,
&running_th, &running_th,
&paused_th, &paused_th,
&muted_th,
bridge.as_ref(), bridge.as_ref(),
voice_sample.as_ref(), voice_sample.as_ref(),
split, split,
@@ -344,6 +364,7 @@ impl WasapiCapture {
Ok(CaptureHandle { Ok(CaptureHandle {
running, running,
paused, paused,
muted,
thread, thread,
}) })
} }
@@ -610,6 +631,9 @@ fn capture_loop(
event_sink: &EventSink, event_sink: &EventSink,
running: &AtomicBool, running: &AtomicBool,
paused: &AtomicBool, paused: &AtomicBool,
// Mic-only live mute (FR-CAP-7): zeroes the decoded mic samples so the
// recording, transcript, and meter all go silent while loopback continues.
muted: &AtomicBool,
bridge: Option<&Arc<MicBridge>>, bridge: Option<&Arc<MicBridge>>,
voice_sample: Option<&Arc<VoiceSample>>, voice_sample: Option<&Arc<VoiceSample>>,
// FR-SPK: when true, the loopback WAV is stereo L=mic / R=loopback (the mic // FR-SPK: when true, the loopback WAV is stereo L=mic / R=loopback (the mic
@@ -762,7 +786,14 @@ fn capture_loop(
write_wav_bytes(w, &bytes, &session.format, &mic)? write_wav_bytes(w, &bytes, &session.format, &mic)?
}; };
} }
let mono = decode_mono_f32(&bytes, &session.format)?; let mut mono = decode_mono_f32(&bytes, &session.format)?;
// Mic muted: replace the decoded samples with silence before anything
// downstream sees them — the recording's mic channel, the bridge, the
// transcript feed, the meter, and the voiceprint sample all go quiet.
// Loopback (`is_loopback`) is never muted this way.
if !is_loopback && muted.load(Ordering::Relaxed) {
mono.iter_mut().for_each(|s| *s = 0.0);
}
// Mic: feed the shared bridge (resampled to the loopback's rate) so the // Mic: feed the shared bridge (resampled to the loopback's rate) so the
// loopback thread can fold it into the recording. // loopback thread can fold it into the recording.
+693 -82
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
//! Enterprise deployment: seed default settings from an admin-supplied `.ini`
//! on **first run only** (before any `settings.json` exists).
//!
//! An admin mass-deploying WhispAssist (GPO / SCCM / Intune) drops a
//! `wa-defaults.ini` and every fresh install picks it up once, seeding
//! `settings.json` with their chosen defaults (record-by-default, preferred
//! backend, retention, model to auto-download, …) — all via native Windows file
//! deployment, no WiX custom actions. See `docs/enterprise-deployment.md`.
//!
//! **Guardrail (CLAUDE.md):** the file must never carry secrets. Keys that look
//! like credentials are ignored here as defense in depth — API keys / OAuth
//! tokens live only in the OS credential store.
use crate::models::Settings;
use serde_json::{Map, Value};
use std::path::PathBuf;
/// Special (non-`Settings`) INI key: when truthy, the first-run seed also fetches
/// the configured `whisper_model` in the background so the machine is ready
/// offline. Stripped before the settings merge.
const AUTO_DOWNLOAD_KEY: &str = "auto_download_model";
/// Candidate locations, first found wins:
/// 1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide enterprise path.
/// 2. `<exe dir>\wa-defaults.ini` — the bundled template / per-install override.
fn candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(program_data) = std::env::var("ProgramData") {
paths.push(PathBuf::from(program_data).join("WhispAssist").join("wa-defaults.ini"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
paths.push(dir.join("wa-defaults.ini"));
}
}
paths
}
/// Reads the first existing defaults file and produces the seeded settings plus
/// the whisper model id to auto-download (if `auto_download_model` was set).
/// `None` when no file exists or it contains no overrides (the shipped template
/// is fully commented, so normal installs get exactly today's behavior).
pub fn seed_settings_from_defaults() -> Option<(Settings, Option<String>)> {
let text = candidate_paths()
.into_iter()
.find_map(|p| std::fs::read_to_string(p).ok())?;
seed_from_ini(&text)
}
/// Testable core: parse INI text → merge onto the built-in defaults.
fn seed_from_ini(text: &str) -> Option<(Settings, Option<String>)> {
let mut overrides = parse_ini(text);
if overrides.is_empty() {
return None;
}
// Pull the non-Settings auto-download flag out before the merge.
let auto_download = overrides
.remove(AUTO_DOWNLOAD_KEY)
.map(|v| truthy(&v))
.unwrap_or(false);
// Merge overrides onto the default settings' JSON form, then deserialize.
// Unknown keys (typos) are ignored — `Settings` has no deny_unknown_fields.
let mut base = match serde_json::to_value(crate::commands::default_settings()) {
Ok(Value::Object(map)) => map,
_ => return None,
};
for (k, v) in overrides {
base.insert(k, v);
}
let settings: Settings = serde_json::from_value(Value::Object(base)).ok()?;
let model = if auto_download {
Some(settings.whisper_model.clone())
} else {
None
};
Some((settings, model))
}
/// Minimal INI reader: skips blanks, `;`/`#` comments and `[section]` headers;
/// splits each `key = value` on the first `=`; coerces values to bool / integer /
/// string so serde lands them on the typed `Settings` fields. Silently drops any
/// key that looks like a secret (guardrail — no credentials in the deploy file).
fn parse_ini(text: &str) -> Map<String, Value> {
let mut map = Map::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty()
|| line.starts_with(';')
|| line.starts_with('#')
|| line.starts_with('[')
{
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_string();
let value = value.trim();
if key.is_empty() || looks_like_secret(&key) {
continue;
}
map.insert(key, coerce(value));
}
map
}
/// `true`/`false` → bool, all-integer → number, everything else → string.
fn coerce(value: &str) -> Value {
match value.to_ascii_lowercase().as_str() {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
if let Ok(n) = value.parse::<i64>() {
return Value::Number(n.into());
}
Value::String(value.to_string())
}
fn truthy(v: &Value) -> bool {
matches!(v, Value::Bool(true)) || matches!(v, Value::String(s) if s.eq_ignore_ascii_case("true"))
}
/// Defense in depth: never seed anything that smells like a credential.
fn looks_like_secret(key: &str) -> bool {
let k = key.to_ascii_lowercase();
["key", "token", "secret", "credential", "password"]
.iter()
.any(|needle| k.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fully_commented_file_is_a_noop() {
let ini = "; default_record = true\n# preferred_backend = cpu\n[general]\n\n";
assert!(seed_from_ini(ini).is_none());
}
#[test]
fn coerces_bool_int_and_string_fields() {
let ini = "default_record = true\nretention_max_age_days = 90\npreferred_backend = cpu\n";
let (settings, model) = seed_from_ini(ini).expect("overrides present");
assert!(settings.default_record);
assert_eq!(settings.retention_max_age_days, Some(90));
assert_eq!(settings.preferred_backend, "cpu");
assert!(model.is_none());
}
#[test]
fn auto_download_returns_the_configured_model() {
let ini = "whisper_model = base.en-q5_1\nauto_download_model = true\n";
let (_settings, model) = seed_from_ini(ini).expect("overrides present");
assert_eq!(model.as_deref(), Some("base.en-q5_1"));
}
#[test]
fn unset_fields_keep_their_defaults() {
let ini = "default_record = true\n";
let (settings, _) = seed_from_ini(ini).unwrap();
// microphone stays on, auto_start stays off — only the named key changed.
assert!(settings.microphone_enabled);
assert!(!settings.auto_start);
}
#[test]
fn secret_keys_are_ignored() {
let ini = "anthropic_api_key = sk-should-be-dropped\ndefault_record = true\n";
let map = parse_ini(ini);
assert!(!map.contains_key("anthropic_api_key"));
assert!(map.contains_key("default_record"));
}
}
+101
View File
@@ -58,6 +58,55 @@ pub fn assign_by_overlap(segments: &mut [TranscriptSegment], spans: &[SpeakerSpa
} }
} }
/// Split-layout attribution (FR-SPK): decides per segment between "You" (mic
/// channel voice activity) and the far side's diarized speakers by comparing
/// the *total* voiced overlap on each channel, not by picking the single
/// longest span — a long far-side diarizer span could otherwise swallow a
/// segment the user spoke most of, showing their words under "Speaker N".
/// The mic channel is physically the user's voice alone, so channel evidence
/// outranks cluster evidence; ties go to "You" (mislabeling the user's own
/// words as someone else is the worse failure). A segment with no voiced
/// overlap on either channel keeps its prior label rather than guessing.
// ponytail: whole-segment labels — a segment genuinely containing both sides
// still gets one speaker; the upgrade path is transcribing each channel
// separately so segments can never mix voices.
pub fn assign_split(
segments: &mut [TranscriptSegment],
you_spans: &[(u64, u64)],
far_vad: &[(u64, u64)],
far_spans: &[SpeakerSpan],
) {
fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 {
a1.min(b1).saturating_sub(a0.max(b0))
}
for seg in segments.iter_mut() {
let mic_ms: u64 = you_spans
.iter()
.map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e))
.sum();
let far_ms: u64 = far_vad
.iter()
.map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e))
.sum();
if mic_ms == 0 && far_ms == 0 {
continue;
}
if mic_ms >= far_ms {
seg.speaker = "You".to_string();
} else if let Some(span) = far_spans
.iter()
.map(|sp| (overlap(seg.start_ms, seg.end_ms, sp.start_ms, sp.end_ms), sp))
.filter(|(o, _)| *o > 0)
.max_by_key(|(o, _)| *o)
.map(|(_, sp)| sp)
{
seg.speaker = span.speaker.clone();
}
// Far side voiced but no diarizer span overlaps (e.g. a sub-700ms span
// was filtered): keep the prior label rather than guess.
}
}
/// sherpa-onnx-backed diarizer: pyannote segmentation + speaker-embedding + /// sherpa-onnx-backed diarizer: pyannote segmentation + speaker-embedding +
/// fast clustering (ADR-0005, T4.1). `Diarize::compute` needs `&mut self`; it's /// fast clustering (ADR-0005, T4.1). `Diarize::compute` needs `&mut self`; it's
/// wrapped in a `Mutex` to satisfy `Diarizer: Sync` — diarization is a /// wrapped in a `Mutex` to satisfy `Diarizer: Sync` — diarization is a
@@ -210,6 +259,58 @@ mod overlap_tests {
assign_by_overlap(&mut segments, &[]); assign_by_overlap(&mut segments, &[]);
assert_eq!(segments[0].speaker, "S1"); assert_eq!(segments[0].speaker, "S1");
} }
#[test]
fn split_labels_a_mic_dominant_segment_you_even_against_a_longer_far_span() {
// The user spoke 0-4000ms; the far side 4000-6000ms — but the far
// cluster span covers the whole window, so the old merged max-overlap
// pick handed the entire segment (the user's words included) to the
// far speaker. Channel totals must side with the mic instead.
let mut segments = vec![segment(0, 6000)];
let you = vec![(0u64, 4000u64)];
let far_vad = vec![(4000u64, 6000u64)];
let far_spans = vec![span(0, 6000, "S1")]; // long far cluster span
assign_split(&mut segments, &you, &far_vad, &far_spans);
assert_eq!(segments[0].speaker, "You");
}
#[test]
fn split_ties_go_to_you() {
let mut segments = vec![segment(0, 2000)];
let you = vec![(0u64, 1000u64)];
let far_vad = vec![(1000u64, 2000u64)];
let far_spans = vec![span(1000, 2000, "S1")];
assign_split(&mut segments, &you, &far_vad, &far_spans);
assert_eq!(segments[0].speaker, "You");
}
#[test]
fn split_assigns_the_best_far_span_when_the_far_side_dominates() {
let mut segments = vec![segment(0, 3000)];
let you = vec![(0u64, 500u64)];
let far_vad = vec![(500u64, 3000u64)];
let far_spans = vec![span(500, 1000, "S1"), span(1000, 3000, "S2")];
assign_split(&mut segments, &you, &far_vad, &far_spans);
assert_eq!(segments[0].speaker, "S2");
}
#[test]
fn split_keeps_the_prior_label_when_both_channels_are_silent() {
let mut segments = vec![segment(5000, 6000)];
segments[0].speaker = "S9".to_string();
assign_split(&mut segments, &[(0, 1000)], &[(0, 1000)], &[span(0, 1000, "S1")]);
assert_eq!(segments[0].speaker, "S9");
}
#[test]
fn split_keeps_the_prior_label_when_far_is_voiced_but_no_far_span_overlaps() {
// Far VAD hears speech but every diarizer span was filtered (sub-700ms):
// don't guess a label.
let mut segments = vec![segment(0, 1000)];
segments[0].speaker = "S3".to_string();
assign_split(&mut segments, &[], &[(0, 1000)], &[span(2000, 3000, "S1")]);
assert_eq!(segments[0].speaker, "S3");
}
} }
#[cfg(all(test, feature = "diarization"))] #[cfg(all(test, feature = "diarization"))]
+109 -2
View File
@@ -9,6 +9,7 @@ pub mod audio;
pub mod briefs; pub mod briefs;
pub mod calendar; pub mod calendar;
pub mod commands; pub mod commands;
pub mod deploy;
pub mod diarization; pub mod diarization;
pub mod error; pub mod error;
pub mod hardware; pub mod hardware;
@@ -27,7 +28,8 @@ pub mod vault;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex}; use std::sync::{Arc, Mutex as StdMutex};
use std::thread::JoinHandle; use std::thread::JoinHandle;
use tauri::tray::TrayIcon; use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri::Manager; use tauri::Manager;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -155,6 +157,14 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
// Opt-in launch-at-login (NFR-RES-4). The macOS launcher arg is required
// by the signature but unused on Windows, where enable/disable writes a
// per-user HKCU\...\Run entry (no admin). Off until the user (or an
// enterprise deploy file) turns `auto_start` on.
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
// In-memory streaming of recordings for the player (FR-REC-5): decrypts // In-memory streaming of recordings for the player (FR-REC-5): decrypts
// on the fly so no plaintext audio is ever written to disk. // on the fly so no plaintext audio is ever written to disk.
.register_uri_scheme_protocol("waaudio", |_ctx, request| { .register_uri_scheme_protocol("waaudio", |_ctx, request| {
@@ -165,13 +175,84 @@ pub fn run() {
session: Mutex::new(None), session: Mutex::new(None),
}) })
.setup(move |app| { .setup(move |app| {
// Single tray icon (the `trayIcon` in tauri.conf.json was removed so
// this is the only one). It carries a Show/Quit menu and, on
// left-click, restores the window — the always-available way back
// from "close to tray".
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
let tray = tauri::tray::TrayIconBuilder::new() let show_item = MenuItem::with_id(app, "show", "Show WhispAssist", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let tray = TrayIconBuilder::new()
.icon(icon) .icon(icon)
.tooltip("WhispAssist — idle") .tooltip("WhispAssist — idle")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?; .build(app)?;
app.manage(TrayHandle(tray)); app.manage(TrayHandle(tray));
// First-run enterprise deploy seeding (deploy.rs): if no settings.json
// exists yet and an admin dropped a wa-defaults.ini, seed settings once
// and optionally fetch the configured model in the background. One-shot
// — guarded by the settings file's absence, so it never re-runs and adds
// nothing to idle cost (NFR-RES-1).
if !crate::paths::settings_path().exists() {
if let Some((seeded, model_to_download)) = deploy::seed_settings_from_defaults() {
match commands::save_settings(&seeded) {
Ok(()) => {
tracing::info!("seeded settings.json from wa-defaults.ini");
if let Some(id) = model_to_download {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = commands::download_model(
app_handle,
commands::DownloadModelArgs {
kind: "whisper".into(),
id,
},
)
.await
{
tracing::warn!("deploy auto-download of model failed: {e:?}");
}
});
}
}
Err(e) => {
tracing::error!("first-run deploy seeding failed to write settings: {e:?}")
}
}
}
}
// Reconcile launch-at-login with the persisted preference (NFR-RES-4):
// if the user opted in but the OS entry is missing (e.g. after a
// reinstall or a deploy file that set auto_start), restore it. One-shot.
{
use tauri_plugin_autostart::ManagerExt;
let manager = app.autolaunch();
if commands::load_settings().auto_start && !manager.is_enabled().unwrap_or(false) {
if let Err(e) = manager.enable() {
tracing::warn!("failed to restore auto-start entry: {e}");
}
}
}
// Reminders (Phase 8, T8.6, FR-CAL-5) are Windows-scheduled toasts, not // Reminders (Phase 8, T8.6, FR-CAL-5) are Windows-scheduled toasts, not
// an app-side timer — Windows itself is what's "polling", so this stays // an app-side timer — Windows itself is what's "polling", so this stays
// within NFR-RES-1. init() just registers the AppUserModelID. // within NFR-RES-1. init() just registers the AppUserModelID.
@@ -265,6 +346,17 @@ pub fn run() {
}); });
Ok(()) Ok(())
}) })
// Close to tray (keep running in background): when the setting is on,
// the window X hides instead of quitting; the tray "Quit" is the real
// exit. Off → default behavior (closing the window quits the app).
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if commands::load_settings().close_to_tray {
api.prevent_close();
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::start_recording, commands::start_recording,
commands::stop_recording, commands::stop_recording,
@@ -272,6 +364,7 @@ pub fn run() {
commands::recording_playback_path, commands::recording_playback_path,
commands::pause_recording, commands::pause_recording,
commands::resume_recording, commands::resume_recording,
commands::toggle_microphone_mute,
commands::set_recording_retention, commands::set_recording_retention,
commands::acknowledge_recording_consent, commands::acknowledge_recording_consent,
commands::update_live_notes, commands::update_live_notes,
@@ -283,6 +376,9 @@ pub fn run() {
commands::list_audio_devices, commands::list_audio_devices,
commands::list_input_devices, commands::list_input_devices,
commands::set_preferred_backend, commands::set_preferred_backend,
commands::set_auto_start,
commands::monitor_audio_level,
commands::stress_test_hardware,
commands::list_models, commands::list_models,
commands::list_whisper_languages, commands::list_whisper_languages,
commands::download_npu_package, commands::download_npu_package,
@@ -311,6 +407,7 @@ pub fn run() {
commands::generate_summary, commands::generate_summary,
commands::confirm_action_items, commands::confirm_action_items,
commands::generate_tags, commands::generate_tags,
commands::enhance_notes,
commands::llm_setup_suggestions, commands::llm_setup_suggestions,
commands::pull_ollama_model, commands::pull_ollama_model,
commands::import_pst, commands::import_pst,
@@ -355,6 +452,16 @@ pub fn run() {
.expect("error while running WhispAssist"); .expect("error while running WhispAssist");
} }
/// Restore the main window from the tray (show + unminimize + focus). Shared by
/// the tray left-click and the "Show WhispAssist" menu item.
fn show_main_window(app: &tauri::AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
/// Used by `commands.rs` to keep the tray tooltip honest about capture state (FR-CAP-4). /// Used by `commands.rs` to keep the tray tooltip honest about capture state (FR-CAP-4).
pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) { pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) {
if let Some(tray) = app.try_state::<TrayHandle>() { if let Some(tray) = app.try_state::<TrayHandle>() {
+11
View File
@@ -354,6 +354,17 @@ pub struct Settings {
pub mcp_expose: String, // none|selected|all pub mcp_expose: String, // none|selected|all
#[serde(default)] #[serde(default)]
pub mcp_expose_recordings: bool, pub mcp_expose_recordings: bool,
/// Launch WhispAssist automatically at login (opt-in, NFR-RES-4). OFF by
/// default; toggled via `set_auto_start`, which writes a per-user
/// `HKCU\...\Run` entry through `tauri-plugin-autostart` (no admin). An
/// enterprise deploy file may set this to `true` (see `deploy.rs`).
#[serde(default)]
pub auto_start: bool,
/// Closing the window hides WhispAssist to the system tray instead of
/// quitting, so it keeps running in the background (tray "Quit" really
/// exits). ON by default; the tray icon is the always-available way back.
#[serde(default = "default_true")]
pub close_to_tray: bool,
} }
fn default_mcp_transport() -> String { fn default_mcp_transport() -> String {
+14
View File
@@ -276,6 +276,10 @@ pub trait Store: Send + Sync {
) -> Result<Vec<MeetingListItem>, StoreError>; ) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>; async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>; async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
/// Overwrite a meeting's lifecycle `status` (e.g. mark a background import
/// `transcribing` while it runs, or `error` if it fails). `finalize_meeting`
/// is still the only path to `ready`.
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError>;
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>; async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
/// (Re)builds this meeting's FTS index row from the current title and /// (Re)builds this meeting's FTS index row from the current title and
/// whatever's on disk/in the DB for transcript/notes/summary/tags (Phase /// whatever's on disk/in the DB for transcript/notes/summary/tags (Phase
@@ -976,6 +980,16 @@ impl Store for SqliteStore {
Ok(()) Ok(())
} }
async fn set_meeting_status(&self, id: &MeetingId, status: &str) -> Result<(), StoreError> {
sqlx::query("UPDATE meetings SET status = ?, updated_at = ? WHERE id = ?")
.bind(status)
.bind(now_unix())
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> { async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> {
write_artifact( write_artifact(
&paths::meeting_dir(id).join("notes.md"), &paths::meeting_dir(id).join("notes.md"),
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "WhispAssist", "productName": "WhispAssist",
"version": "0.6.0", "version": "0.7.2",
"identifier": "bet.dou.whispassist", "identifier": "bet.dou.whispassist",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -22,18 +22,18 @@
], ],
"security": { "security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; media-src 'self' http://waaudio.localhost; style-src 'self' 'unsafe-inline'" "csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; media-src 'self' http://waaudio.localhost; style-src 'self' 'unsafe-inline'"
},
"trayIcon": {
"iconPath": "icons/tray.png",
"tooltip": "WhispAssist"
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["msi", "nsis"], "targets": ["msi", "nsis"],
"icon": ["icons/icon.ico"], "icon": ["icons/icon.ico"],
"resources": ["wa-defaults.ini"],
"windows": { "windows": {
"webviewInstallMode": { "type": "downloadBootstrapper" } "webviewInstallMode": { "type": "downloadBootstrapper" },
"nsis": {
"installMode": "both"
}
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "gen/schemas/desktop-schema.json", "$schema": "gen/schemas/desktop-schema.json",
"bundle": { "bundle": {
"resources": ["vulkan-1.dll"] "resources": ["vulkan-1.dll", "wa-defaults.ini"]
} }
} }
+60
View File
@@ -0,0 +1,60 @@
; ============================================================================
; WhispAssist enterprise deployment defaults (wa-defaults.ini)
; ============================================================================
; Read ONCE, on a machine's FIRST launch (before settings.json exists), to
; seed the app's default settings. After that the user's own settings.json
; wins and this file is ignored. Deploy it with native Windows tooling
; (Group Policy / SCCM / Intune file copy) to either location — the first
; one found wins:
;
; 1. %PROGRAMDATA%\WhispAssist\wa-defaults.ini (machine-wide)
; 2. <install dir>\wa-defaults.ini (this bundled template)
;
; As shipped, every setting below is COMMENTED OUT, so a normal install
; behaves exactly as if this file were absent. Uncomment and edit the lines
; you want to preset. Format is flat "key = value" — no sections required;
; [section] headers, ';' and '#' comment lines are ignored.
;
; Values: true / false for switches; a plain number for numeric fields;
; text otherwise. Unknown / misspelled keys are ignored.
;
; SECURITY: never put secrets here. API keys, OAuth tokens and sync
; passwords live only in the OS credential store; any key containing
; "key", "token", "secret", "credential" or "password" is dropped on read.
; ----------------------------------------------------------------------------
; ---- Recording (ADR-0009) --------------------------------------------------
; Record every meeting to disk by default (consent notice still applies).
; default_record = false
; ---- Transcription backend & model -----------------------------------------
; preferred_backend = auto ; auto | npu | nvidia | amd | intel | cpu
; whisper_model = base.en-q5_1 ; a catalog model id
; whisper_language = auto ; auto | ISO-639-1 code (multilingual model only)
; low_overhead = false
; Download whisper_model in the background on first launch so the machine is
; ready offline. Requires network at first run.
; auto_download_model = false
; ---- Storage & retention (FR-STORE-2) --------------------------------------
; storage_root = C:\ProgramData\WhispAssist\data
; retention_max_age_days = 90
; retention_max_size_gb = 20
; ---- Local LLM / summaries (ADR-0007) --------------------------------------
; llm_provider = ollama ; ollama | custom | anthropic | off
; llm_endpoint = http://localhost:11434
; llm_model = llama3
; ---- Capture & UX ----------------------------------------------------------
; microphone_enabled = true
; auto_record_calendar = false
; theme = system ; system | light | dark
; ---- Startup (NFR-RES-4) ---------------------------------------------------
; Launch WhispAssist automatically at login for the user.
; auto_start = false
; ---- Sync master switch (ADR-0010; targets/creds configured in-app) --------
; sync_enabled = false
+74 -24
View File
@@ -12,6 +12,7 @@
import { recording } from "./lib/stores/recording.svelte"; import { recording } from "./lib/stores/recording.svelte";
import { settings } from "./lib/stores/settings.svelte"; import { settings } from "./lib/stores/settings.svelte";
import { meetings } from "./lib/stores/meetings.svelte"; import { meetings } from "./lib/stores/meetings.svelte";
import { imports } from "./lib/stores/imports.svelte";
import { calendar } from "./lib/stores/calendar.svelte"; import { calendar } from "./lib/stores/calendar.svelte";
import { api, type NoteTemplate, type CalendarEvent } from "./lib/api"; import { api, type NoteTemplate, type CalendarEvent } from "./lib/api";
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -24,6 +25,8 @@
Square, Square,
Trash2, Trash2,
FilePlus, FilePlus,
Mic,
MicOff,
Settings as SettingsIcon, Settings as SettingsIcon,
AlertTriangle, AlertTriangle,
PanelLeftClose, PanelLeftClose,
@@ -131,6 +134,7 @@
recording.init(); recording.init();
settings.load(); settings.load();
meetings.init(); meetings.init();
imports.init(); // live background-import progress for the tracker
calendar.load(); // events power the auto-record timer above calendar.load(); // events power the auto-record timer above
checkVault(); checkVault();
api api
@@ -207,6 +211,18 @@
} else if (e.ctrlKey && e.key === ",") { } else if (e.ctrlKey && e.key === ",") {
e.preventDefault(); e.preventDefault();
showSettings = !showSettings; showSettings = !showSettings;
} else if (
// Press "M" to mute/unmute the mic mid-meeting (FR-CAP-7). Bare key (no
// modifiers) and only while recording with the mic on.
e.key.toLowerCase() === "m" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
recording.state !== "idle" &&
settings.settings.microphone_enabled
) {
e.preventDefault();
recording.toggleMute();
} }
} }
@@ -232,10 +248,16 @@
<div class="app" data-theme={resolvedTheme}> <div class="app" data-theme={resolvedTheme}>
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div> <div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
<header class="bar"> <header class="bar">
<strong>WhispAssist</strong>
<span class="muted">{t("app.tagline")}</span>
<div class="spacer"></div>
{#if recording.state === "idle"} {#if recording.state === "idle"}
<button
class="record-btn"
onclick={startRecording}
title={t("app.record_title")}
aria-keyshortcuts="Control+Shift+R"
>
<Circle size={11} fill="currentColor" aria-hidden="true" />
{t("app.record")}
</button>
<select <select
class="theme-select" class="theme-select"
bind:value={selectedTemplateId} bind:value={selectedTemplateId}
@@ -247,23 +269,6 @@
<option value={tpl.id}>{tpl.name}</option> <option value={tpl.id}>{tpl.name}</option>
{/each} {/each}
</select> </select>
<button
class="import-btn"
onclick={() => (showImport = true)}
title={t("app.add_meeting_title")}
>
<FilePlus size={13} aria-hidden="true" />
{t("app.add_meeting")}
</button>
<button
class="record-btn"
onclick={startRecording}
title={t("app.record_title")}
aria-keyshortcuts="Control+Shift+R"
>
<Circle size={11} fill="currentColor" aria-hidden="true" />
{t("app.record")}
</button>
{:else} {:else}
<button <button
class="stop-btn" class="stop-btn"
@@ -278,6 +283,18 @@
<Trash2 size={12} aria-hidden="true" /> <Trash2 size={12} aria-hidden="true" />
{t("app.cancel")} {t("app.cancel")}
</button> </button>
{/if}
<div class="spacer"></div>
{#if recording.state === "idle"}
<button
class="import-btn"
onclick={() => (showImport = true)}
title={t("app.add_meeting_title")}
>
<FilePlus size={13} aria-hidden="true" />
{t("app.add_meeting")}
</button>
{:else}
<span class="rec"> <span class="rec">
<span class="rec-dot" aria-hidden="true"></span> <span class="rec-dot" aria-hidden="true"></span>
{t("app.recording")} {t("app.recording")}
@@ -289,6 +306,23 @@
micPeak={recording.levelPeakMic} micPeak={recording.levelPeakMic}
showMic={settings.settings.microphone_enabled} showMic={settings.settings.microphone_enabled}
/> />
{#if settings.settings.microphone_enabled}
<button
class="mute-btn"
class:muted={recording.micMuted}
onclick={() => recording.toggleMute()}
aria-pressed={recording.micMuted}
aria-keyshortcuts="M"
title={recording.micMuted ? t("app.unmute_title") : t("app.mute_title")}
>
{#if recording.micMuted}
<MicOff size={14} aria-hidden="true" />
{:else}
<Mic size={14} aria-hidden="true" />
{/if}
<span class="sr-only">{recording.micMuted ? t("app.unmute") : t("app.mute")}</span>
</button>
{/if}
{#if settings.hardware} {#if settings.hardware}
<span class="backend" title={t("app.backend_title")}>{settings.hardware.active}</span> <span class="backend" title={t("app.backend_title")}>{settings.hardware.active}</span>
{/if} {/if}
@@ -588,10 +622,6 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-elevated); background: var(--bg-elevated);
} }
.bar strong {
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.spacer { .spacer {
flex: 1; flex: 1;
} }
@@ -718,6 +748,26 @@
border-radius: var(--radius-full); border-radius: var(--radius-full);
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
} }
.mute-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-full);
cursor: pointer;
}
.mute-btn:hover {
background: var(--bg-hover);
}
.mute-btn.muted {
color: var(--danger, #d33);
border-color: var(--danger, #d33);
background: color-mix(in srgb, var(--danger, #d33) 12%, transparent);
}
.retention { .retention {
display: flex; display: flex;
align-items: center; align-items: center;
+67 -4
View File
@@ -56,6 +56,19 @@ export interface AudioDeviceInfo {
name: string; name: string;
} }
// Quick hardware stress test (Settings ▸ Hardware): per-(backend, model)
// real-time factor, plus the recommended real-time-capable pairing.
export interface StressResult {
backend: string;
model: string;
rtf: number;
realtime: boolean;
}
export interface StressTestResult {
results: StressResult[];
recommended: { backend: string; model: string } | null;
}
export interface LlmStatus { export interface LlmStatus {
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired) provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
reachable: boolean; reachable: boolean;
@@ -83,6 +96,19 @@ export interface LanguageOption {
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error"; export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
// The four ordered phases of a background media import (import://progress).
export type ImportPhase = "prepare" | "transcribe" | "diarize" | "finalize";
// One `import://progress` tick. `state` is active (running), done (finished,
// `elapsedMs` set) or error (`error` message set) for the given `phase`.
export interface ImportProgress {
meetingId: MeetingId;
phase: ImportPhase;
state: "active" | "done" | "error";
elapsedMs: number | null;
error: string | null;
}
export interface MeetingListItem { export interface MeetingListItem {
id: MeetingId; id: MeetingId;
title: string; title: string;
@@ -336,6 +362,12 @@ export interface AppSettings {
audio_output_device: string | null; audio_output_device: string | null;
microphone_enabled: boolean; microphone_enabled: boolean;
audio_input_device: string | null; audio_input_device: string | null;
/** Launch WhispAssist at login (opt-in, off by default; NFR-RES-4). Toggled
* via setAutoStart, which writes a per-user Run entry (no admin). */
auto_start: boolean;
/** Closing the window hides to the tray (keep running in background) instead
* of quitting; on by default. Tray "Quit" is the real exit. */
close_to_tray: boolean;
} }
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011). // Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
@@ -398,6 +430,10 @@ export const api = {
invoke<string>("recording_playback_path", { meetingId }), invoke<string>("recording_playback_path", { meetingId }),
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }), pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }), resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
// Toggle mic mute for the active recording (FR-CAP-7); returns the new muted
// state. Errors if the meeting was started with the mic off.
toggleMicrophoneMute: (meetingId: MeetingId) =>
invoke<boolean>("toggle_microphone_mute", { meetingId }),
setRecordingRetention: (meetingId: MeetingId, record: boolean) => setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
invoke<void>("set_recording_retention", { meetingId, record }), invoke<void>("set_recording_retention", { meetingId, record }),
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"), acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
@@ -417,6 +453,11 @@ export const api = {
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"), listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
setPreferredBackend: (backend: BackendId | "auto") => setPreferredBackend: (backend: BackendId | "auto") =>
invoke<void>("set_preferred_backend", { args: { backend } }), invoke<void>("set_preferred_backend", { args: { backend } }),
setAutoStart: (enabled: boolean) => invoke<void>("set_auto_start", { enabled }),
// Test a device: stream device://level for a few seconds. Resolves when done.
monitorAudioLevel: (kind: "input" | "loopback", deviceId: string | null, durationMs = 6000) =>
invoke<void>("monitor_audio_level", { kind, deviceId, durationMs }),
stressTestHardware: () => invoke<StressTestResult>("stress_test_hardware"),
downloadNpuPackage: () => invoke<void>("download_npu_package"), downloadNpuPackage: () => invoke<void>("download_npu_package"),
downloadDirectmlPackage: () => invoke<void>("download_directml_package"), downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
listModels: () => invoke<ModelInfo[]>("list_models"), listModels: () => invoke<ModelInfo[]>("list_models"),
@@ -437,10 +478,12 @@ export const api = {
invoke<void>("reprocess_transcript", { meetingId, model, language }), invoke<void>("reprocess_transcript", { meetingId, model, language }),
// Manually add a meeting from an existing recording — a local audio/video // Manually add a meeting from an existing recording — a local audio/video
// file path or a URL (YouTube/streaming page or direct media URL). Requires // file path or a URL (YouTube/streaming page or direct media URL). Requires
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. Returns the new // ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. `model` overrides
// meeting's id once transcription + diarization have finished. // the Settings whisper model for this one import. Returns the new meeting's id
importMedia: (source: string, title?: string) => // *immediately*; transcode/transcribe/diarize run in the background and stream
invoke<MeetingId>("import_media", { source, title }), // `import://progress` ticks, finishing with `transcript://finalized`.
importMedia: (source: string, title?: string, model?: string) =>
invoke<MeetingId>("import_media", { source, title, model }),
resumeTranscription: (meetingId: MeetingId) => resumeTranscription: (meetingId: MeetingId) =>
invoke<void>("resume_transcription", { meetingId }), invoke<void>("resume_transcription", { meetingId }),
listMeetings: (filter?: MeetingFilter) => listMeetings: (filter?: MeetingFilter) =>
@@ -460,6 +503,10 @@ export const api = {
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }), deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
updateNotes: (meetingId: MeetingId, markdown: string) => updateNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("update_notes", { meetingId, markdown }), invoke<void>("update_notes", { meetingId, markdown }),
// AI-enhance rough notes into structured Markdown grounded in the transcript
// (Granola-style). Returns the enhanced text; the caller decides to keep it.
enhanceNotes: (meetingId: MeetingId, notes: string) =>
invoke<string>("enhance_notes", { meetingId, notes }),
// dest is a file path for md/pdf/docx/obsidian, a folder for bundle. // dest is a file path for md/pdf/docx/obsidian, a folder for bundle.
// "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4. // "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4.
exportMeeting: ( exportMeeting: (
@@ -588,12 +635,19 @@ export const events = {
onDeviceChanged: ( onDeviceChanged: (
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void, cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
): Promise<UnlistenFn> => listen("recording://device", (e) => cb(e.payload as never)), ): Promise<UnlistenFn> => listen("recording://device", (e) => cb(e.payload as never)),
// Mic mute toggled for the active recording (FR-CAP-7).
onMicMuted: (
cb: (p: { meetingId: string; muted: boolean }) => void,
): Promise<UnlistenFn> => listen("recording://mic", (e) => cb(e.payload as never)),
onSegment: ( onSegment: (
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void, cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)), ): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
onFinalized: ( onFinalized: (
cb: (p: { meetingId: string; segmentCount: number }) => void, cb: (p: { meetingId: string; segmentCount: number }) => void,
): Promise<UnlistenFn> => listen("transcript://finalized", (e) => cb(e.payload as never)), ): Promise<UnlistenFn> => listen("transcript://finalized", (e) => cb(e.payload as never)),
// Per-phase progress of a background media import (feeds the import tracker).
onImportProgress: (cb: (p: ImportProgress) => void): Promise<UnlistenFn> =>
listen("import://progress", (e) => cb(e.payload as never)),
// Live diarization refined the speaker list mid-recording (FR-SPK): updated // Live diarization refined the speaker list mid-recording (FR-SPK): updated
// labels/display names, including the mic speaker resolved to "You". // labels/display names, including the mic speaker resolved to "You".
onDiarizationUpdated: ( onDiarizationUpdated: (
@@ -609,6 +663,15 @@ export const events = {
onHardwareChanged: ( onHardwareChanged: (
cb: (p: { active: BackendId; reason: string }) => void, cb: (p: { active: BackendId; reason: string }) => void,
): Promise<UnlistenFn> => listen("hardware://changed", (e) => cb(e.payload as never)), ): Promise<UnlistenFn> => listen("hardware://changed", (e) => cb(e.payload as never)),
// Live level meter for a device test (Settings ▸ Hardware). `done` marks the
// end of the monitor window.
onDeviceLevel: (
cb: (p: { kind: string; rms?: number; peak?: number; done?: boolean }) => void,
): Promise<UnlistenFn> => listen("device://level", (e) => cb(e.payload as never)),
// Per-(backend, model) progress ticks during the quick stress test.
onStressProgress: (
cb: (p: { backend: string; model: string }) => void,
): Promise<UnlistenFn> => listen("stress://progress", (e) => cb(e.payload as never)),
onNpuDownload: ( onNpuDownload: (
cb: (p: { cb: (p: {
stage: "model" | "runtime" | "done"; stage: "model" | "runtime" | "done";
+102 -21
View File
@@ -1,22 +1,43 @@
<script lang="ts"> <script lang="ts">
// Manually add a meeting from an existing recording (feature: "add a meeting // Manually add a meeting from an existing recording (feature: "add a meeting
// + upload a video URL or audio file"). Transcoding is done by the backend // + upload a video URL or audio file"). Transcoding is done by the backend
// via ffmpeg (+ yt-dlp for URLs) — both external, not bundled — so this is // via ffmpeg (+ yt-dlp for URLs) — both external, not bundled — so this is a
// just a small form: pick a local file or paste a URL, optional title, go. // small form: pick a file or URL, choose the transcription model, go. Import
import { api, errorMessage } from "../api"; // runs in the background (import_media returns as soon as the meeting row
// exists), so this closes immediately and the meetings list shows progress.
import { api, errorMessage, type ModelInfo } from "../api";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { onMount } from "svelte";
import { trapFocus } from "../actions/trapFocus"; import { trapFocus } from "../actions/trapFocus";
import { t } from "../i18n/index.svelte"; import { t } from "../i18n/index.svelte";
import { X, FileUp, Link as LinkIcon } from "@lucide/svelte"; import { X, FileUp, Download } from "@lucide/svelte";
let { onClose, onImported }: { onClose: () => void; onImported: (id: string) => void } = $props(); let { onClose, onImported }: { onClose: () => void; onImported: (id: string) => void } = $props();
// External download pages for the two tools this feature shells out to.
const FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/latest";
const YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest";
// `source` is either a local file path (set via Browse) or a URL (typed). // `source` is either a local file path (set via Browse) or a URL (typed).
let source = $state(""); let source = $state("");
let title = $state(""); let title = $state("");
let model = $state("");
let models = $state<ModelInfo[]>([]);
let busy = $state(false); let busy = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
// Only installed whisper models are selectable; default to the active one so
// the pick matches the user's Settings default unless they change it here.
onMount(async () => {
try {
const all = await api.listModels();
models = all.filter((m) => m.installed);
model = models.find((m) => m.active)?.id ?? models[0]?.id ?? "";
} catch {
models = [];
}
});
async function browse() { async function browse() {
const path = await open({ const path = await open({
multiple: false, multiple: false,
@@ -51,7 +72,7 @@
busy = true; busy = true;
error = null; error = null;
try { try {
const id = await api.importMedia(source.trim(), title.trim() || undefined); const id = await api.importMedia(source.trim(), title.trim() || undefined, model || undefined);
onImported(id); onImported(id);
onClose(); onClose();
} catch (e) { } catch (e) {
@@ -101,17 +122,32 @@
</div> </div>
</label> </label>
<label class="wide"> <div class="grid">
{t("import.title_label")} <em>({t("import.optional")})</em> <label>
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} /> {t("import.model_label")}
</label> <select bind:value={model} disabled={busy || models.length === 0}>
{#each models as m (m.id)}
<option value={m.id}>{m.label}</option>
{/each}
</select>
<span class="hint">{t("import.model_hint")}</span>
</label>
<p class="muted small"> <label>
<LinkIcon size={12} aria-hidden="true" /> {t("import.title_label")} <em>({t("import.optional")})</em>
{t("import.requires_1")} <code>ffmpeg</code> <input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
{t("import.requires_2")} <code>yt-dlp</code> </label>
{t("import.requires_3")} </div>
</p>
<div class="tools">
<span class="muted small">{t("import.requires")}</span>
<button class="tool" type="button" onclick={() => api.openUrl(FFMPEG_URL)}>
<Download size={12} aria-hidden="true" /> ffmpeg
</button>
<button class="tool" type="button" onclick={() => api.openUrl(YTDLP_URL)}>
<Download size={12} aria-hidden="true" /> yt-dlp
</button>
</div>
{#if error} {#if error}
<p class="error">{error}</p> <p class="error">{error}</p>
@@ -121,6 +157,7 @@
<button class="primary" onclick={doImport} disabled={!source.trim() || busy}> <button class="primary" onclick={doImport} disabled={!source.trim() || busy}>
{busy ? t("import.importing") : t("import.import")} {busy ? t("import.importing") : t("import.import")}
</button> </button>
<span class="muted small note">{t("import.background_note")}</span>
<button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button> <button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button>
</div> </div>
</div> </div>
@@ -143,7 +180,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
width: min(520px, 100%); width: min(540px, 100%);
max-height: 90vh; max-height: 90vh;
overflow: auto; overflow: auto;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
@@ -179,7 +216,8 @@
font-weight: 400; font-weight: 400;
color: var(--muted); color: var(--muted);
} }
input { input,
select {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0.4rem 0.55rem; padding: 0.4rem 0.55rem;
@@ -189,10 +227,18 @@
color: var(--fg); color: var(--fg);
font: inherit; font: inherit;
} }
input:focus-visible { input:focus-visible,
select:focus-visible {
border-color: var(--accent); border-color: var(--accent);
outline: none; outline: none;
} }
.hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
font-weight: 400;
color: var(--muted);
}
.row { .row {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
@@ -213,18 +259,49 @@
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
cursor: pointer; cursor: pointer;
} }
/* Model + title side by side on wide panels, stacked when cramped. */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 0.75rem;
}
@media (max-width: 460px) {
.grid {
grid-template-columns: 1fr;
}
}
.tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.9rem;
}
.tool {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--bg-hover, transparent);
color: var(--accent);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
cursor: pointer;
}
.tool:hover {
border-color: var(--accent);
}
.muted { .muted {
color: var(--muted); color: var(--muted);
} }
.small { .small {
font-size: 0.8rem; font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.3rem;
} }
.error { .error {
color: var(--danger, #d33); color: var(--danger, #d33);
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.75rem;
} }
.actions { .actions {
display: flex; display: flex;
@@ -232,6 +309,10 @@
gap: 0.6rem; gap: 0.6rem;
margin-top: 1rem; margin-top: 1rem;
} }
.actions .note {
flex: 1;
line-height: 1.2;
}
.actions .primary { .actions .primary {
background: var(--accent); background: var(--accent);
color: var(--accent-fg, #fff); color: var(--accent-fg, #fff);
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
// Domino's-pizza-tracker-style progress for a background media import: four
// ordered steps, the running one pulses, finished ones show how long they
// took. Fed by the `imports` store (import://progress events). Renders nothing
// until the first tick arrives. Design per ui-ux-pro-max: color is never the
// only signal (icon + label + time), tabular figures for the timers, and the
// pulse is dropped under prefers-reduced-motion.
import { imports } from "../stores/imports.svelte";
import { t } from "../i18n/index.svelte";
import { AudioLines, Captions, Users, FileCheck2, Check, X } from "@lucide/svelte";
let { meetingId }: { meetingId: string } = $props();
const run = $derived(imports.get(meetingId));
const ICONS = {
prepare: AudioLines,
transcribe: Captions,
diarize: Users,
finalize: FileCheck2,
} as const;
// ms → compact, human duration for a finished step ("820 ms", "4.3s", "2m 05s").
function fmtDur(ms: number | null): string {
if (ms == null) return "";
if (ms < 1000) return `${ms} ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
const m = Math.floor(s / 60);
const rem = Math.round(s % 60);
return `${m}m ${String(rem).padStart(2, "0")}s`;
}
</script>
{#if run}
<section class="tracker" aria-label={t("import.tracker.label")}>
<header>
{#if run.error}
<span class="head err">{t("import.tracker.failed")}</span>
{:else if run.done}
<span class="head ok">{t("import.tracker.done")}</span>
{:else}
<span class="head">{t("import.tracker.running")}</span>
{/if}
</header>
<ol class="steps" aria-live="polite">
{#each run.phases as p (p.phase)}
{@const Icon = ICONS[p.phase]}
<li class="step {p.state}">
<div class="node">
{#if p.state === "done"}
<Check size={18} aria-hidden="true" />
{:else if p.state === "error"}
<X size={18} aria-hidden="true" />
{:else}
<Icon size={18} aria-hidden="true" />
{/if}
</div>
<div class="meta">
<span class="name">{t(`import.phase.${p.phase}`)}</span>
<span class="time">
{#if p.state === "done"}{fmtDur(p.elapsedMs)}
{:else if p.state === "active"}{t("import.tracker.working")}
{:else if p.state === "error"}{t("import.tracker.stopped")}
{/if}
</span>
</div>
</li>
{/each}
</ol>
{#if run.error}
<p class="msg">{run.error}</p>
{/if}
</section>
{/if}
<style>
.tracker {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel, var(--bg-elevated));
padding: 0.85rem 1rem 1rem;
}
header {
margin-bottom: 0.9rem;
}
.head {
font-size: 0.85rem;
font-weight: 600;
color: var(--fg);
}
.head.ok {
color: var(--success);
}
.head.err {
color: var(--danger);
}
.steps {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.step {
flex: 1;
position: relative;
text-align: center;
min-width: 0;
}
/* Connector from the previous node's center to this one's (each step is the
same width, so -50%→+50% spans center to center), sitting behind the node. */
.step::before {
content: "";
position: absolute;
top: 17px;
left: -50%;
width: 100%;
height: 2px;
background: var(--border);
z-index: 0;
}
.step:first-child::before {
display: none;
}
.step.done::before,
.step.active::before,
.step.error::before {
background: var(--accent);
}
.node {
position: relative;
z-index: 1;
width: 36px;
height: 36px;
margin: 0 auto 0.45rem;
display: grid;
place-items: center;
border-radius: var(--radius-full);
border: 2px solid var(--border);
background: var(--bg);
color: var(--muted);
}
.step.active .node {
border-color: var(--accent);
background: var(--accent-soft, transparent);
color: var(--accent);
animation: pulse 1.4s ease-out infinite;
}
.step.done .node {
border-color: var(--success);
background: var(--success);
color: #fff;
}
.step.error .node {
border-color: var(--danger);
background: var(--danger);
color: #fff;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0 0.2rem;
}
.name {
font-size: 0.78rem;
font-weight: 500;
color: var(--muted);
line-height: 1.2;
}
.step.active .name,
.step.done .name {
color: var(--fg);
}
.time {
font-size: 0.72rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-height: 1em;
}
.step.active .time {
color: var(--accent);
}
.msg {
margin: 0.85rem 0 0;
font-size: 0.8rem;
color: var(--danger);
word-break: break-word;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
}
70% {
box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.step.active .node {
animation: none;
box-shadow: 0 0 0 3px var(--accent-soft, transparent);
}
}
</style>
+54 -5
View File
@@ -24,6 +24,25 @@
"settings.transcription.switch_multilingual": "Switch to a multilingual model above to choose a language.", "settings.transcription.switch_multilingual": "Switch to a multilingual model above to choose a language.",
"settings.hardware.title": "Hardware", "settings.hardware.title": "Hardware",
"settings.hardware.refresh": "Refresh",
"settings.hardware.test_output": "Test",
"settings.hardware.test_mic": "Test",
"settings.hardware.testing": "Listening…",
"settings.hardware.play_tone": "Play tone",
"settings.hardware.stress_title": "Quick stress test",
"settings.hardware.stress_hint": "Benchmarks your installed models on each available backend and recommends the most accurate one that still keeps up with live speech. Takes a moment.",
"settings.hardware.stress_run": "Run stress test",
"settings.hardware.stress_running": "Running…",
"settings.hardware.stress_progress": "Benchmarking {pair}…",
"settings.hardware.stress_recommend": "Recommended: {backend} + {model}",
"settings.hardware.stress_apply": "Apply",
"settings.hardware.stress_none": "No installed model keeps up with live speech on this hardware — try a smaller model.",
"settings.hardware.stress_backend": "Backend",
"settings.hardware.stress_model": "Model",
"settings.hardware.stress_rtf": "Speed (×real-time)",
"settings.hardware.stress_realtime": "Live?",
"settings.hardware.stress_yes": "Yes",
"settings.hardware.stress_no": "No",
"settings.hardware.active_backend": "Active backend", "settings.hardware.active_backend": "Active backend",
"settings.hardware.model_meta": "· model {size}", "settings.hardware.model_meta": "· model {size}",
"settings.hardware.preferred_backend": "Preferred backend", "settings.hardware.preferred_backend": "Preferred backend",
@@ -278,6 +297,7 @@
"settings.privacy.locked_word": "locked", "settings.privacy.locked_word": "locked",
"settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.", "settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.",
"settings.privacy.password": "Password", "settings.privacy.password": "Password",
"settings.privacy.show_password": "Show password",
"settings.privacy.unlock": "Unlock", "settings.privacy.unlock": "Unlock",
"settings.privacy.vault_unlocked_1": "Vault is ", "settings.privacy.vault_unlocked_1": "Vault is ",
"settings.privacy.unlocked_word": "unlocked", "settings.privacy.unlocked_word": "unlocked",
@@ -320,12 +340,23 @@
"import.optional": "optional", "import.optional": "optional",
"import.title_placeholder": "Defaults to the file name", "import.title_placeholder": "Defaults to the file name",
"import.filter_av": "Audio / video", "import.filter_av": "Audio / video",
"import.requires_1": "Requires", "import.model_label": "Transcription model",
"import.requires_2": "installed and on your PATH (plus", "import.model_hint": "Recorded with the meeting so you can see how it was transcribed.",
"import.requires_3": "for URLs). WhispAssist doesn't bundle them.", "import.requires": "Needs these on your PATH (not bundled):",
"import.importing": "Importing… this can take a while", "import.background_note": "Runs in the background — track it in the list.",
"import.importing": "Starting…",
"import.import": "Import", "import.import": "Import",
"import.cancel": "Cancel", "import.cancel": "Cancel",
"import.tracker.label": "Import progress",
"import.tracker.running": "Importing…",
"import.tracker.done": "Import complete",
"import.tracker.failed": "Import failed",
"import.tracker.working": "working…",
"import.tracker.stopped": "stopped",
"import.phase.prepare": "Transcode",
"import.phase.transcribe": "Transcribe",
"import.phase.diarize": "Identify speakers",
"import.phase.finalize": "Finalize",
"tagchip.filter": "Filter meetings tagged \"{tag}\"", "tagchip.filter": "Filter meetings tagged \"{tag}\"",
"tagchip.remove": "Remove tag {tag}", "tagchip.remove": "Remove tag {tag}",
@@ -345,6 +376,10 @@
"app.cancel": "Cancel", "app.cancel": "Cancel",
"app.cancel_title": "Discard this recording and delete it", "app.cancel_title": "Discard this recording and delete it",
"app.recording": "Recording…", "app.recording": "Recording…",
"app.mute": "Mute microphone",
"app.unmute": "Unmute microphone",
"app.mute_title": "Mute microphone (M)",
"app.unmute_title": "Unmute microphone (M)",
"app.backend_title": "Active transcription backend", "app.backend_title": "Active transcription backend",
"app.retention_title": "Save audio as .wav for this meeting", "app.retention_title": "Save audio as .wav for this meeting",
"app.saving": "saving", "app.saving": "saving",
@@ -397,6 +432,7 @@
"transcript.heading": "Transcript", "transcript.heading": "Transcript",
"transcript.title_aria": "Meeting title", "transcript.title_aria": "Meeting title",
"transcript.transcribed_with": "Transcribed with",
"transcript.lang_title": "Transcription language", "transcript.lang_title": "Transcription language",
"transcript.lang_auto": "auto-detecting…", "transcript.lang_auto": "auto-detecting…",
"transcript.show": "Show transcript", "transcript.show": "Show transcript",
@@ -428,9 +464,18 @@
"notes.italic": "Italic", "notes.italic": "Italic",
"notes.h1": "Heading 1", "notes.h1": "Heading 1",
"notes.h2": "Heading 2", "notes.h2": "Heading 2",
"notes.h3": "Heading 3",
"notes.bullet": "Bullet list", "notes.bullet": "Bullet list",
"notes.numbered": "Numbered list",
"notes.quote": "Quote",
"notes.divider": "Divider",
"notes.checkbox_title": "Checkbox", "notes.checkbox_title": "Checkbox",
"notes.checkbox_aria": "Checkbox list item", "notes.checkbox_aria": "Checkbox list item",
"notes.enhance": "Enhance",
"notes.enhancing": "Enhancing…",
"notes.enhance_title": "Expand these notes into structured Markdown using the transcript (AI)",
"notes.enhanced_note": "Notes enhanced from the transcript.",
"notes.undo_enhance": "Undo",
"notes.edit_raw": "Edit the raw markdown", "notes.edit_raw": "Edit the raw markdown",
"notes.render": "Render the markdown", "notes.render": "Render the markdown",
"notes.editor": "Editor", "notes.editor": "Editor",
@@ -527,5 +572,9 @@
"settings.recording.consent_ack": "acknowledged", "settings.recording.consent_ack": "acknowledged",
"settings.recording.consent_not": "not yet acknowledged", "settings.recording.consent_not": "not yet acknowledged",
"settings.recording.auto_label": "Auto-start recording when a calendar event begins", "settings.recording.auto_label": "Auto-start recording when a calendar event begins",
"settings.recording.auto_hint": "Only while WhispAssist is open. When an imported calendar event's start time arrives, a recording begins automatically (using your default retention setting above). Nothing runs in the background — the timer is armed only while the app is running. Import events under Settings → Calendar." "settings.recording.auto_hint": "Only while WhispAssist is open. When an imported calendar event's start time arrives, a recording begins automatically (using your default retention setting above). Nothing runs in the background — the timer is armed only while the app is running. Import events under Settings → Calendar.",
"settings.recording.autostart_label": "Launch WhispAssist at login",
"settings.recording.autostart_hint": "Starts WhispAssist automatically when you sign in to Windows. Off by default; installs a per-user startup entry (no admin required) and does not begin recording on its own.",
"settings.recording.close_tray_label": "Close to system tray",
"settings.recording.close_tray_hint": "Closing the window keeps WhispAssist running in the background instead of quitting. Reopen it from the tray icon; use the tray's Quit to exit fully. On by default."
} }
+63
View File
@@ -0,0 +1,63 @@
// Live per-meeting progress of background media imports (feeds ImportTracker).
// Fed entirely by `import://progress` events emitted by `import_media`; kept in
// memory only (the meeting's `status` badge is the persistent story after a
// restart). See commands.rs `run_import_pipeline`.
import { events, type ImportPhase, type ImportProgress, type MeetingId } from "../api";
export type PhaseState = "pending" | "active" | "done" | "error";
// The four phases in the order the backend runs (and the tracker renders) them.
export const IMPORT_PHASES: ImportPhase[] = ["prepare", "transcribe", "diarize", "finalize"];
export interface PhaseInfo {
phase: ImportPhase;
state: PhaseState;
elapsedMs: number | null;
}
export interface ImportRun {
meetingId: MeetingId;
phases: PhaseInfo[];
error: string | null;
done: boolean;
}
function freshRun(meetingId: MeetingId): ImportRun {
return {
meetingId,
phases: IMPORT_PHASES.map((phase) => ({ phase, state: "pending", elapsedMs: null })),
error: null,
done: false,
};
}
class ImportsStore {
runs = $state<Record<MeetingId, ImportRun>>({});
get(meetingId: MeetingId): ImportRun | undefined {
return this.runs[meetingId];
}
async init() {
await events.onImportProgress((p) => this.apply(p));
}
private apply(p: ImportProgress) {
// Re-read through the record after inserting so we mutate the $state proxy,
// not the raw object (Svelte 5 deep reactivity only tracks the proxy).
if (!this.runs[p.meetingId]) this.runs[p.meetingId] = freshRun(p.meetingId);
const run = this.runs[p.meetingId];
const info = run.phases.find((x) => x.phase === p.phase);
if (!info) return;
info.state = p.state;
if (p.state === "done") info.elapsedMs = p.elapsedMs;
if (p.state === "error") {
run.error = p.error;
run.done = true;
}
if (p.phase === "finalize" && p.state === "done") run.done = true;
}
}
export const imports = new ImportsStore();
+22
View File
@@ -22,6 +22,9 @@ class RecordingStore {
* (FR-CAP-7); stays 0 when the mic is disabled or not recording. */ * (FR-CAP-7); stays 0 when the mic is disabled or not recording. */
levelRmsMic = $state(0); levelRmsMic = $state(0);
levelPeakMic = $state(0); levelPeakMic = $state(0);
/** Mic muted for the in-flight meeting (FR-CAP-7): mic channel goes silent
* while loopback keeps recording. Toggled by the "M" key / mute button. */
micMuted = $state(false);
/** Set while a capture-device reconnect is in progress; cleared on recovery (FR-CAP-6). */ /** Set while a capture-device reconnect is in progress; cleared on recovery (FR-CAP-6). */
deviceNotice = $state<string | null>(null); deviceNotice = $state<string | null>(null);
/** Live notes redesign: freeform text typed in the Notes pane while recording. */ /** Live notes redesign: freeform text typed in the Notes pane while recording. */
@@ -72,6 +75,22 @@ class RecordingStore {
await events.onDeviceChanged(({ recovered, message }) => { await events.onDeviceChanged(({ recovered, message }) => {
this.deviceNotice = recovered ? null : message; this.deviceNotice = recovered ? null : message;
}); });
// Keep mute state in sync even if it was toggled elsewhere (e.g. a future
// tray control), not just from this store's toggleMute().
await events.onMicMuted(({ muted }) => {
this.micMuted = muted;
});
}
/** Toggle mic mute for the active recording (FR-CAP-7); no-op if not
* recording. Optimistically flips, then reconciles with the backend result. */
async toggleMute() {
if (!this.meetingId || this.state === "idle") return;
try {
this.micMuted = await api.toggleMicrophoneMute(this.meetingId);
} catch {
// Mic off for this meeting (or capture gone) — nothing to mute.
}
} }
async start(title?: string, record = false, templateId?: string, calendarEventId?: string) { async start(title?: string, record = false, templateId?: string, calendarEventId?: string) {
@@ -79,6 +98,7 @@ class RecordingStore {
this.speakers = []; this.speakers = [];
this.retention = record; this.retention = record;
this.deviceNotice = null; this.deviceNotice = null;
this.micMuted = false;
this.notesText = ""; this.notesText = "";
this.segmentNotes.clear(); this.segmentNotes.clear();
// T8.7/FR-TRX-4: whatever language is currently configured in Settings // T8.7/FR-TRX-4: whatever language is currently configured in Settings
@@ -98,6 +118,7 @@ class RecordingStore {
this.levelPeak = 0; this.levelPeak = 0;
this.levelRmsMic = 0; this.levelRmsMic = 0;
this.levelPeakMic = 0; this.levelPeakMic = 0;
this.micMuted = false;
this.deviceNotice = null; this.deviceNotice = null;
} }
@@ -116,6 +137,7 @@ class RecordingStore {
this.levelPeak = 0; this.levelPeak = 0;
this.levelRmsMic = 0; this.levelRmsMic = 0;
this.levelPeakMic = 0; this.levelPeakMic = 0;
this.micMuted = false;
this.deviceNotice = null; this.deviceNotice = null;
this.notesText = ""; this.notesText = "";
this.segmentNotes.clear(); this.segmentNotes.clear();
+14
View File
@@ -48,6 +48,8 @@ const DEFAULT_SETTINGS: AppSettings = {
audio_output_device: null, // system default render device (FR-CAP-1) audio_output_device: null, // system default render device (FR-CAP-1)
microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7) microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7)
audio_input_device: null, // system default capture device audio_input_device: null, // system default capture device
auto_start: false, // launch at login — opt-in, off by default (NFR-RES-4)
close_to_tray: true, // closing the window hides to tray; on by default
}; };
class SettingsStore { class SettingsStore {
@@ -383,6 +385,18 @@ class SettingsStore {
return this.patch({ default_record: on }); return this.patch({ default_record: on });
} }
/** Launch-at-login toggle (NFR-RES-4). Goes through its own command (not
* patch) since the backend also writes the per-user OS Run entry; that
* command persists auto_start itself, so we just mirror it locally. */
async setAutoStart(on: boolean) {
this.settings = { ...this.settings, auto_start: on };
try {
await api.setAutoStart(on);
} catch {
this.backendStub = true;
}
}
setRetentionPolicy(maxAgeDays: number | null, maxSizeGb: number | null) { setRetentionPolicy(maxAgeDays: number | null, maxSizeGb: number | null) {
return this.patch({ retention_max_age_days: maxAgeDays, retention_max_size_gb: maxSizeGb }); return this.patch({ retention_max_age_days: maxAgeDays, retention_max_size_gb: maxSizeGb });
} }
+440 -68
View File
@@ -9,9 +9,16 @@
import { t, i18n, LOCALES } from "../i18n/index.svelte"; import { t, i18n, LOCALES } from "../i18n/index.svelte";
import ConsentNotice from "../components/ConsentNotice.svelte"; import ConsentNotice from "../components/ConsentNotice.svelte";
import HostedAiBanner from "../components/HostedAiBanner.svelte"; import HostedAiBanner from "../components/HostedAiBanner.svelte";
import LevelMeter from "../components/LevelMeter.svelte";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { api, errorMessage, events } from "../api"; import { api, errorMessage, events } from "../api";
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api"; import type {
BackendId,
StressTestResult,
SyncKind,
SyncTargetConfig,
SyncTargetInfo,
} from "../api";
import { trapFocus } from "../actions/trapFocus"; import { trapFocus } from "../actions/trapFocus";
import { import {
X, X,
@@ -23,7 +30,11 @@
CalendarDays, CalendarDays,
UploadCloud, UploadCloud,
ShieldCheck, ShieldCheck,
Lock,
LockOpen,
Sparkles, Sparkles,
Volume2,
Zap,
RefreshCw, RefreshCw,
ChevronRight, ChevronRight,
RotateCcw, RotateCcw,
@@ -381,11 +392,98 @@
let showConsent = $state(false); let showConsent = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null); let testResult = $state<{ ok: boolean; message: string } | null>(null);
// ---- Audio device test (live level meter) ----
let monitorKind = $state<"input" | "loopback" | null>(null);
let monitorRms = $state(0);
let monitorPeak = $state(0);
let monitorUnlisten: (() => void) | null = null;
function stopMonitor() {
monitorUnlisten?.();
monitorUnlisten = null;
monitorKind = null;
monitorRms = 0;
monitorPeak = 0;
}
async function testDevice(kind: "input" | "loopback") {
if (monitorKind) return;
monitorKind = kind;
monitorRms = 0;
monitorPeak = 0;
monitorUnlisten = await events.onDeviceLevel((p) => {
if (p.kind !== kind) return;
if (p.done) {
stopMonitor();
return;
}
monitorRms = p.rms ?? 0;
monitorPeak = p.peak ?? 0;
});
const deviceId =
kind === "input"
? (settings.settings.audio_input_device ?? null)
: (settings.settings.audio_output_device ?? null);
try {
await api.monitorAudioLevel(kind, deviceId, 6000);
} catch (e) {
testResult = { ok: false, message: errorMessage(e) };
} finally {
stopMonitor();
}
}
// A 440Hz beep to the default output so the user can confirm speakers work.
function playTone() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 440;
gain.gain.value = 0.15;
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.5);
osc.onended = () => ctx.close();
} catch {
/* no Web Audio available */
}
}
// ---- Quick hardware stress test ----
let stressRunning = $state(false);
let stressProgress = $state<string | null>(null);
let stressResult = $state<StressTestResult | null>(null);
let stressError = $state<string | null>(null);
async function runStressTest() {
if (stressRunning) return;
stressRunning = true;
stressError = null;
stressResult = null;
const un = await events.onStressProgress((p) => {
stressProgress = `${p.backend} · ${p.model}`;
});
try {
stressResult = await api.stressTestHardware();
} catch (e) {
stressError = errorMessage(e);
} finally {
un();
stressProgress = null;
stressRunning = false;
}
}
async function applyRecommendation() {
const r = stressResult?.recommended;
if (!r) return;
await settings.setPreferredBackend(r.backend as BackendId | "auto");
await settings.patch({ whisper_model: r.model });
}
// ---- At-rest encryption vault (T8.8, FR-SEC-3) ---- // ---- At-rest encryption vault (T8.8, FR-SEC-3) ----
let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null); let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null);
let vaultPw = $state(""); let vaultPw = $state("");
let vaultPw2 = $state(""); let vaultPw2 = $state("");
let vaultMsg = $state<string | null>(null); let vaultMsg = $state<string | null>(null);
let vaultMsgError = $state(false);
let showVaultPw = $state(false);
async function loadVault() { async function loadVault() {
try { try {
vault = await api.vaultStatus(); vault = await api.vaultStatus();
@@ -394,42 +492,46 @@
} }
} }
onMount(loadVault); onMount(loadVault);
function setVaultMsg(msg: string | null, isError = false) {
vaultMsg = msg;
vaultMsgError = isError;
}
async function enableVault() { async function enableVault() {
vaultMsg = null; setVaultMsg(null);
try { try {
await api.enableVault(vaultPw); await api.enableVault(vaultPw);
vaultPw = ""; vaultPw = "";
vaultMsg = "Vault enabled and unlocked."; setVaultMsg("Vault enabled and unlocked.");
await loadVault(); await loadVault();
} catch (e) { } catch (e) {
vaultMsg = errorMessage(e); setVaultMsg(errorMessage(e), true);
} }
} }
async function unlockVault() { async function unlockVault() {
vaultMsg = null; setVaultMsg(null);
try { try {
await api.unlockVault(vaultPw); await api.unlockVault(vaultPw);
vaultPw = ""; vaultPw = "";
vaultMsg = "Unlocked."; setVaultMsg("Unlocked.");
await loadVault(); await loadVault();
} catch (e) { } catch (e) {
vaultMsg = errorMessage(e); setVaultMsg(errorMessage(e), true);
} }
} }
async function lockVault() { async function lockVault() {
await api.lockVault(); await api.lockVault();
vaultMsg = "Locked."; setVaultMsg("Locked.");
await loadVault(); await loadVault();
} }
async function changeVaultPassword() { async function changeVaultPassword() {
vaultMsg = null; setVaultMsg(null);
try { try {
await api.changeVaultPassword(vaultPw, vaultPw2); await api.changeVaultPassword(vaultPw, vaultPw2);
vaultPw = ""; vaultPw = "";
vaultPw2 = ""; vaultPw2 = "";
vaultMsg = "Password changed."; setVaultMsg("Password changed.");
} catch (e) { } catch (e) {
vaultMsg = errorMessage(e); setVaultMsg(errorMessage(e), true);
} }
} }
@@ -754,13 +856,40 @@
</label> </label>
<p class="muted">{t("settings.recording.auto_hint")}</p> <p class="muted">{t("settings.recording.auto_hint")}</p>
<label class="row">
<input
type="checkbox"
checked={settings.settings.auto_start}
onchange={(e) => settings.setAutoStart((e.target as HTMLInputElement).checked)}
/>
<span>{t("settings.recording.autostart_label")}</span>
</label>
<p class="muted">{t("settings.recording.autostart_hint")}</p>
<label class="row">
<input
type="checkbox"
checked={settings.settings.close_to_tray}
onchange={(e) =>
settings.patch({ close_to_tray: (e.target as HTMLInputElement).checked })}
/>
<span>{t("settings.recording.close_tray_label")}</span>
</label>
<p class="muted">{t("settings.recording.close_tray_hint")}</p>
{#if showConsent} {#if showConsent}
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} /> <ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
{/if} {/if}
</section> </section>
{:else if section === "hardware"} {:else if section === "hardware"}
<section> <section>
<h3>{t("settings.hardware.title")}</h3> <div class="actions">
<h3>{t("settings.hardware.title")}</h3>
<button class="ghost" onclick={() => settings.loadHardware()}>
<RefreshCw size={14} aria-hidden="true" />
{t("settings.hardware.refresh")}
</button>
</div>
{#if settings.hardware} {#if settings.hardware}
<div class="row"> <div class="row">
{t("settings.hardware.active_backend")}<code>{settings.hardware.active}</code> {t("settings.hardware.active_backend")}<code>{settings.hardware.active}</code>
@@ -804,6 +933,20 @@
</select> </select>
</label> </label>
<p class="muted">{t("settings.hardware.recording_device_hint")}</p> <p class="muted">{t("settings.hardware.recording_device_hint")}</p>
<div class="device-test">
<button onclick={() => testDevice("loopback")} disabled={monitorKind !== null}>
<Volume2 size={13} aria-hidden="true" />
{monitorKind === "loopback"
? t("settings.hardware.testing")
: t("settings.hardware.test_output")}
</button>
<button onclick={playTone} disabled={monitorKind !== null}>
{t("settings.hardware.play_tone")}
</button>
{#if monitorKind === "loopback"}
<LevelMeter rms={monitorRms} peak={monitorPeak} />
{/if}
</div>
<label <label
>{t("settings.hardware.microphone")} >{t("settings.hardware.microphone")}
@@ -825,6 +968,75 @@
</select> </select>
</label> </label>
<p class="muted">{t("settings.hardware.mic_hint")}</p> <p class="muted">{t("settings.hardware.mic_hint")}</p>
<div class="device-test">
<button
onclick={() => testDevice("input")}
disabled={monitorKind !== null || !settings.settings.microphone_enabled}
>
<Mic size={13} aria-hidden="true" />
{monitorKind === "input"
? t("settings.hardware.testing")
: t("settings.hardware.test_mic")}
</button>
{#if monitorKind === "input"}
<LevelMeter rms={monitorRms} peak={monitorPeak} />
{/if}
</div>
<h4>{t("settings.hardware.stress_title")}</h4>
<p class="muted">{t("settings.hardware.stress_hint")}</p>
<button onclick={runStressTest} disabled={stressRunning}>
<Zap size={13} aria-hidden="true" />
{stressRunning ? t("settings.hardware.stress_running") : t("settings.hardware.stress_run")}
</button>
{#if stressProgress}
<p class="muted">{t("settings.hardware.stress_progress", { pair: stressProgress })}</p>
{/if}
{#if stressError}<p class="muted err">{stressError}</p>{/if}
{#if stressResult}
{#if stressResult.recommended}
<div class="stress-rec">
<ShieldCheck size={14} aria-hidden="true" />
<span
>{t("settings.hardware.stress_recommend", {
backend: stressResult.recommended.backend,
model: stressResult.recommended.model,
})}</span
>
<button class="primary" onclick={applyRecommendation}
>{t("settings.hardware.stress_apply")}</button
>
</div>
{:else}
<p class="muted">{t("settings.hardware.stress_none")}</p>
{/if}
<table class="stress-table">
<thead>
<tr>
<th>{t("settings.hardware.stress_backend")}</th>
<th>{t("settings.hardware.stress_model")}</th>
<th>{t("settings.hardware.stress_rtf")}</th>
<th>{t("settings.hardware.stress_realtime")}</th>
</tr>
</thead>
<tbody>
{#each stressResult.results as r (r.backend + r.model)}
<tr>
<td>{r.backend}</td>
<td>{r.model}</td>
<td class="num">{r.rtf.toFixed(2)}×</td>
<td>
{#if r.realtime}
<Check size={13} aria-hidden="true" /> {t("settings.hardware.stress_yes")}
{:else}
{t("settings.hardware.stress_no")}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if settings.hardware.npu?.present} {#if settings.hardware.npu?.present}
{@const npu = settings.hardware.npu} {@const npu = settings.hardware.npu}
@@ -1918,67 +2130,118 @@
{/if} {/if}
{#if vault} {#if vault}
<h4>{t("settings.privacy.vault_title")}</h4> <div
{#if !vault.enabled} class="vault-card"
<p class="muted">{t("settings.privacy.vault_intro")}</p> class:locked={vault.enabled && !vault.unlocked}
<div class="grid"> class:unlocked={vault.enabled && vault.unlocked}
<label class="wide" >
>{t("settings.privacy.vault_password")}<input <div class="vault-head">
type="password" {#if !vault.enabled}
bind:value={vaultPw} <ShieldCheck size={18} aria-hidden="true" />
/></label {:else if !vault.unlocked}
> <Lock size={18} aria-hidden="true" />
{:else}
<LockOpen size={18} aria-hidden="true" />
{/if}
<h4>{t("settings.privacy.vault_title")}</h4>
{#if vault.enabled}
<span class="badge" class:busy={!vault.unlocked}>
{vault.unlocked
? t("settings.privacy.unlocked_word")
: t("settings.privacy.locked_word")}
</span>
{/if}
</div> </div>
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
>{t("settings.privacy.enable_vault")}</button {#if !vault.enabled}
> <p class="muted">{t("settings.privacy.vault_intro")}</p>
<p class="muted">{t("settings.privacy.vault_pw_hint")}</p> <div class="pw-row">
{:else if !vault.unlocked} <input
<p class="muted"> type={showVaultPw ? "text" : "password"}
{t("settings.privacy.vault_locked_1")}<strong
>{t("settings.privacy.locked_word")}</strong
>{t("settings.privacy.vault_locked_2")}
</p>
<div class="grid">
<label class="wide"
>{t("settings.privacy.password")}<input
type="password"
bind:value={vaultPw} bind:value={vaultPw}
/></label placeholder={t("settings.privacy.vault_password")}
> aria-label={t("settings.privacy.vault_password")}
</div> />
<button class="primary" onclick={unlockVault} disabled={!vaultPw} <button
>{t("settings.privacy.unlock")}</button type="button"
> class="icon pw-toggle"
{:else} onclick={() => (showVaultPw = !showVaultPw)}
<p class="muted"> aria-label={t("settings.privacy.show_password")}
{t("settings.privacy.vault_unlocked_1")}<strong title={t("settings.privacy.show_password")}
>{t("settings.privacy.unlocked_word")}</strong
>{t("settings.privacy.vault_unlocked_2")}
</p>
<button onclick={lockVault}>{t("settings.privacy.lock_now")}</button>
<details>
<summary>{t("settings.privacy.change_password")}</summary>
<div class="grid">
<label class="wide"
>{t("settings.privacy.current_password")}<input
type="password"
bind:value={vaultPw}
/></label
>
<label class="wide"
>{t("settings.privacy.new_password")}<input
type="password"
bind:value={vaultPw2}
/></label
> >
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
size={14}
aria-hidden="true"
/>{/if}
</button>
</div> </div>
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8} <button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
>{t("settings.privacy.change_password")}</button >{t("settings.privacy.enable_vault")}</button
> >
</details> <p class="muted hint">{t("settings.privacy.vault_pw_hint")}</p>
{/if} {:else if !vault.unlocked}
{#if vaultMsg}<p class="muted">{vaultMsg}</p>{/if} <p class="muted">
{t("settings.privacy.vault_locked_1")}<strong
>{t("settings.privacy.locked_word")}</strong
>{t("settings.privacy.vault_locked_2")}
</p>
<div class="pw-row">
<input
type={showVaultPw ? "text" : "password"}
bind:value={vaultPw}
placeholder={t("settings.privacy.password")}
aria-label={t("settings.privacy.password")}
onkeydown={(e) => e.key === "Enter" && vaultPw && unlockVault()}
/>
<button
type="button"
class="icon pw-toggle"
onclick={() => (showVaultPw = !showVaultPw)}
aria-label={t("settings.privacy.show_password")}
title={t("settings.privacy.show_password")}
>
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
size={14}
aria-hidden="true"
/>{/if}
</button>
</div>
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
>{t("settings.privacy.unlock")}</button
>
{:else}
<p class="muted">
{t("settings.privacy.vault_unlocked_1")}<strong
>{t("settings.privacy.unlocked_word")}</strong
>{t("settings.privacy.vault_unlocked_2")}
</p>
<button onclick={lockVault}>
<Lock size={14} aria-hidden="true" />
{t("settings.privacy.lock_now")}
</button>
<details>
<summary>{t("settings.privacy.change_password")}</summary>
<div class="grid">
<label class="wide"
>{t("settings.privacy.current_password")}<input
type="password"
bind:value={vaultPw}
/></label
>
<label class="wide"
>{t("settings.privacy.new_password")}<input
type="password"
bind:value={vaultPw2}
/></label
>
</div>
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
>{t("settings.privacy.change_password")}</button
>
</details>
{/if}
{#if vaultMsg}<p class="vault-msg" class:error={vaultMsgError}>{vaultMsg}</p>{/if}
</div>
{/if} {/if}
</section> </section>
{:else if section === "language"} {:else if section === "language"}
@@ -2429,6 +2692,115 @@
color: var(--accent, #2563eb); color: var(--accent, #2563eb);
border-color: currentColor; border-color: currentColor;
} }
.vault-card {
margin-top: 0.6rem;
padding: 0.85rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md, 8px);
background: var(--bg-elevated);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.vault-card.locked {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.vault-card.unlocked {
border-color: color-mix(in srgb, var(--success, #16a34a) 45%, var(--border));
}
.vault-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.vault-head h4 {
margin: 0;
}
.vault-head :global(svg) {
color: var(--muted);
}
.vault-card.locked .vault-head :global(svg) {
color: var(--accent);
}
.vault-card.unlocked .vault-head :global(svg) {
color: var(--success, #16a34a);
}
.vault-head .badge {
margin-left: auto;
}
.pw-row {
display: flex;
align-items: center;
gap: 0.4rem;
max-width: 22rem;
}
.pw-row input {
flex: 1;
}
.pw-toggle {
flex: none;
}
.vault-card .hint {
margin: 0;
}
.vault-msg {
margin: 0;
font-size: 0.85rem;
color: var(--success, #16a34a);
}
.vault-msg.error {
color: var(--danger);
}
.device-test {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0.35rem 0 0.6rem;
}
.device-test :global(.meter) {
flex: 1;
min-width: 8rem;
}
.stress-rec {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0;
padding: 0.55rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
border-radius: var(--radius-md, 8px);
background: color-mix(in srgb, var(--accent) 10%, var(--bg));
}
.stress-rec :global(svg) {
color: var(--accent);
}
.stress-rec span {
flex: 1;
}
.stress-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
font-size: 0.82rem;
}
.stress-table th,
.stress-table td {
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--border);
}
.stress-table th {
color: var(--muted);
font-weight: 600;
}
.stress-table .num {
font-variant-numeric: tabular-nums;
text-align: right;
}
.err {
color: var(--danger);
}
.npu-package { .npu-package {
margin-top: 0.6rem; margin-top: 0.6rem;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
+65 -44
View File
@@ -64,7 +64,7 @@
// nonce so re-clicking the same segment still jumps). Reading seekNonce is // nonce so re-clicking the same segment still jumps). Reading seekNonce is
// what makes this effect re-run. // what makes this effect re-run.
$effect(() => { $effect(() => {
player.seekNonce; void player.seekNonce;
const ms = player.seekMs; const ms = player.seekMs;
if (ms == null || !audioEl) return; if (ms == null || !audioEl) return;
audioEl.currentTime = ms / 1000; audioEl.currentTime = ms / 1000;
@@ -770,47 +770,54 @@
{#if editableItems.length === 0} {#if editableItems.length === 0}
<p class="muted">{t("summary.ai_empty")}</p> <p class="muted">{t("summary.ai_empty")}</p>
{:else} {:else}
<!-- Two-row card per item: the action text owns the full first row (it
was unreadable when six controls shared one row in this narrow
pane); owner/due/reminder are a secondary meta row beneath it. -->
<ul class="action-items"> <ul class="action-items">
{#each editableItems as item, i (i)} {#each editableItems as item, i (i)}
<li> <li>
<input <div class="ai-main">
type="checkbox" <input
bind:checked={item.confirmed} type="checkbox"
aria-label={t("summary.confirmed")} bind:checked={item.confirmed}
title={t("summary.confirmed")} aria-label={t("summary.confirmed")}
/> title={t("summary.confirmed")}
<input />
class="ai-text" <input
bind:value={item.text} class="ai-text"
placeholder={t("summary.ai_text_placeholder")} bind:value={item.text}
aria-label={t("summary.ai_text_aria")} placeholder={t("summary.ai_text_placeholder")}
/> aria-label={t("summary.ai_text_aria")}
<input />
class="ai-owner" <button
value={item.owner ?? ""} class="ai-del"
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)} onclick={() => removeActionItem(i)}
placeholder={t("summary.owner")} title={t("summary.ai_delete_title")}
aria-label={t("summary.owner")} aria-label={t("summary.ai_delete_aria")}
/> >
<input <X size={14} aria-hidden="true" />
type="date" </button>
class="due-date" </div>
aria-label={t("summary.due_date")} <div class="ai-meta">
value={dueDateInput(item.due_at)} <input
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)} class="ai-owner"
/> value={item.owner ?? ""}
<label class="remind" title={t("summary.reminder_title")}> oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} /> placeholder={t("summary.owner")}
<Bell size={14} aria-hidden="true" /> aria-label={t("summary.owner")}
</label> />
<button <input
class="ai-del" type="date"
onclick={() => removeActionItem(i)} class="due-date"
title={t("summary.ai_delete_title")} aria-label={t("summary.due_date")}
aria-label={t("summary.ai_delete_aria")} value={dueDateInput(item.due_at)}
> onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
<X size={14} aria-hidden="true" /> />
</button> <label class="remind" title={t("summary.reminder_title")}>
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
<Bell size={14} aria-hidden="true" />
</label>
</div>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -1080,9 +1087,9 @@
} }
ul.action-items li { ul.action-items li {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
padding: 0.25rem 0; padding: 0.45rem 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
ul.action-items label { ul.action-items label {
@@ -1090,14 +1097,28 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
} }
.ai-main {
display: flex;
align-items: center;
gap: 0.35rem;
}
.ai-text { .ai-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 0.82rem; font-size: 0.85rem;
}
/* Meta row indented under the text (past the confirm checkbox), wrapping
rather than crushing its inputs when the pane is narrow. */
.ai-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
padding-left: 1.4rem;
} }
.ai-owner { .ai-owner {
flex: none; flex: 1;
width: 5rem; min-width: 5rem;
font-size: 0.75rem; font-size: 0.75rem;
} }
.ai-del { .ai-del {
+206 -5
View File
@@ -6,19 +6,27 @@
import { meetings } from "../stores/meetings.svelte"; import { meetings } from "../stores/meetings.svelte";
import { settings } from "../stores/settings.svelte"; import { settings } from "../stores/settings.svelte";
import { player } from "../stores/player.svelte"; import { player } from "../stores/player.svelte";
import { api, type SpeakerInfo } from "../api"; import { api, errorMessage, type SpeakerInfo } from "../api";
import { t } from "../i18n/index.svelte"; import { t } from "../i18n/index.svelte";
import { renderMarkdown } from "../markdown"; import { renderMarkdown } from "../markdown";
import { save, open } from "@tauri-apps/plugin-dialog"; import { save, open } from "@tauri-apps/plugin-dialog";
import { layout, clamp } from "../stores/layout.svelte"; import { layout, clamp } from "../stores/layout.svelte";
import { imports } from "../stores/imports.svelte";
import Splitter from "../components/Splitter.svelte"; import Splitter from "../components/Splitter.svelte";
import ImportTracker from "../components/ImportTracker.svelte";
import { import {
Bold, Bold,
Italic, Italic,
Heading1, Heading1,
Heading2, Heading2,
Heading3,
List, List,
ListOrdered,
ListChecks, ListChecks,
Quote,
Minus,
Sparkles,
Undo2,
FileDown, FileDown,
FileText, FileText,
FolderOutput, FolderOutput,
@@ -66,6 +74,10 @@
let editorEl: HTMLTextAreaElement | undefined = $state(); let editorEl: HTMLTextAreaElement | undefined = $state();
let saveTimer: ReturnType<typeof setTimeout> | undefined; let saveTimer: ReturnType<typeof setTimeout> | undefined;
let loadedForId: string | null = null; let loadedForId: string | null = null;
// The server copy the buffer was last synced against — lets the effect below
// tell a server-side notes change (speaker rename, reprocess) apart from the
// user's own unsaved edits.
let lastServerNotes: string | null = null;
// Transcript/notes split (FR-UX-1): resizable (drag the Splitter) and // Transcript/notes split (FR-UX-1): resizable (drag the Splitter) and
// each side independently hideable, shared across the finalized-meeting // each side independently hideable, shared across the finalized-meeting
@@ -103,14 +115,23 @@
selectedSegmentMs = null; selectedSegmentMs = null;
}); });
// Sync the editor buffer whenever a different meeting is selected. // Sync the editor buffer whenever a different meeting is selected — and when
// the *server* copy of the same meeting's notes changes underneath us (a
// speaker rename rewrites notes.md's dialogue tags, reprocess regenerates it).
// A buffer with unsaved local edits is never clobbered: it only adopts the
// server copy when it still equals the last-synced one.
$effect(() => { $effect(() => {
const m = meetings.selected; const m = meetings.selected;
if (m && m.id !== loadedForId) { if (m && m.id !== loadedForId) {
notesText = m.notes_markdown; notesText = m.notes_markdown;
lastServerNotes = m.notes_markdown;
loadedForId = m.id; loadedForId = m.id;
} else if (m && m.id === loadedForId && m.notes_markdown !== lastServerNotes) {
if (notesText === lastServerNotes) notesText = m.notes_markdown;
lastServerNotes = m.notes_markdown;
} else if (!m) { } else if (!m) {
loadedForId = null; loadedForId = null;
lastServerNotes = null;
} }
}); });
@@ -158,6 +179,70 @@
scheduleSave(); scheduleSave();
} }
// Slash commands: typing "/todo" (etc.) at the start of a line and pressing
// Space/Enter swaps it for the matching Markdown prefix. Reuses the same
// line-prefix model as the toolbar buttons — no rich inline menu.
// ponytail: line-prefix slash only; add a picker popover if users ask.
const SLASH_COMMANDS: Record<string, string> = {
h1: "# ",
h2: "## ",
h3: "### ",
todo: "- [ ] ",
bullet: "- ",
num: "1. ",
quote: "> ",
divider: "---\n",
};
function handleNotesKeydown(e: KeyboardEvent) {
if (e.key !== "Enter" && e.key !== " ") return;
const el = editorEl;
if (!el) return;
const { selectionStart: s, value } = el;
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
const match = /^\/(\w+)$/.exec(value.slice(lineStart, s));
if (!match) return;
const prefix = SLASH_COMMANDS[match[1].toLowerCase()];
if (prefix === undefined) return;
e.preventDefault();
const head = value.slice(0, lineStart) + prefix;
notesText = head + value.slice(s);
queueMicrotask(() => {
el.focus();
el.selectionStart = el.selectionEnd = head.length;
});
scheduleSave();
}
// AI-enhance (Granola-style): expand the user's rough notes into structured
// Markdown grounded in the transcript, via the configured LlmProvider (local
// by default, no new egress). Keeps a one-step Undo so we never silently lose
// what the user typed.
let enhancing = $state(false);
let enhanceError = $state<string | null>(null);
let notesBeforeEnhance = $state<string | null>(null);
async function enhanceNotes() {
const m = meetings.selected;
if (!m || enhancing) return;
enhancing = true;
enhanceError = null;
try {
const enhanced = await api.enhanceNotes(m.id, notesText);
notesBeforeEnhance = notesText;
notesText = enhanced;
scheduleSave();
} catch (e) {
enhanceError = errorMessage(e);
} finally {
enhancing = false;
}
}
function undoEnhance() {
if (notesBeforeEnhance === null) return;
notesText = notesBeforeEnhance;
notesBeforeEnhance = null;
scheduleSave();
}
async function exportMd() { async function exportMd() {
const m = meetings.selected; const m = meetings.selected;
if (!m) return; if (!m) return;
@@ -247,6 +332,15 @@
reprocessing = true; reprocessing = true;
try { try {
await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined); await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined);
// Re-transcribe rebuilds notes.md server-side (merging saved manual notes),
// but the meeting stays selected (same id), so the buffer-sync $effect —
// which only fires on an id change — won't pick it up. Resync explicitly so
// the notes pane updates in place instead of only after a restart.
const updated = meetings.selected;
if (updated && updated.id === m.id) {
notesText = updated.notes_markdown;
loadedForId = updated.id;
}
} finally { } finally {
reprocessing = false; reprocessing = false;
} }
@@ -262,14 +356,23 @@
onchange={onTitleChange} onchange={onTitleChange}
aria-label={t("transcript.title_aria")} aria-label={t("transcript.title_aria")}
/> />
{#if m.status === "transcribing" || imports.get(m.id)}
<div class="import-strip"><ImportTracker meetingId={m.id} /></div>
{:else if m.model_used}
<p class="engine-meta" title={t("transcript.transcribed_with")}>
{t("transcript.transcribed_with")}
<strong>{m.model_used}</strong>{#if m.backend_used}
· {m.backend_used}{/if}
</p>
{/if}
<div <div
class="split" class="split"
style="grid-template-columns: {splitColumns()};" style="grid-template-columns: {splitColumns()};"
bind:clientWidth={splitWidth} bind:clientWidth={splitWidth}
> >
<!-- svelte-ignore a11y_no_static_element_interactions -- wheel/touchmove <!-- wheel/touchmove here only note "the user scrolled by hand" to pause
here only note "the user scrolled by hand" to pause playback playback auto-scroll; the pane isn't an interactive control. -->
auto-scroll; the pane isn't an interactive control. --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="pane transcript" class="pane transcript"
class:collapsed={layout.transcriptCollapsed} class:collapsed={layout.transcriptCollapsed}
@@ -423,6 +526,43 @@
> >
<ListChecks size={14} aria-hidden="true" /> <ListChecks size={14} aria-hidden="true" />
</button> </button>
<button
onclick={() => insertLinePrefix("### ")}
title={t("notes.h3")}
aria-label={t("notes.h3")}
>
<Heading3 size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("1. ")}
title={t("notes.numbered")}
aria-label={t("notes.numbered")}
>
<ListOrdered size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("> ")}
title={t("notes.quote")}
aria-label={t("notes.quote")}
>
<Quote size={14} aria-hidden="true" />
</button>
<button
onclick={() => insertLinePrefix("---\n")}
title={t("notes.divider")}
aria-label={t("notes.divider")}
>
<Minus size={14} aria-hidden="true" />
</button>
<button
class="enhance"
onclick={enhanceNotes}
disabled={enhancing}
title={t("notes.enhance_title")}
>
<Sparkles size={14} aria-hidden="true" class={enhancing ? "spin" : ""} />
{enhancing ? t("notes.enhancing") : t("notes.enhance")}
</button>
<button <button
class="toggle" class="toggle"
onclick={() => (notesPreview = !notesPreview)} onclick={() => (notesPreview = !notesPreview)}
@@ -459,6 +599,17 @@
Obsidian Obsidian
</button> </button>
</div> </div>
{#if enhanceError}
<p class="enhance-bar error" role="alert">{enhanceError}</p>
{:else if notesBeforeEnhance !== null}
<div class="enhance-bar">
<span>{t("notes.enhanced_note")}</span>
<button class="link" onclick={undoEnhance}>
<Undo2 size={13} aria-hidden="true" />
{t("notes.undo_enhance")}
</button>
</div>
{/if}
<div class="editor-preview"> <div class="editor-preview">
{#if notesPreview} {#if notesPreview}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() --> <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
@@ -468,6 +619,7 @@
bind:this={editorEl} bind:this={editorEl}
bind:value={notesText} bind:value={notesText}
oninput={scheduleSave} oninput={scheduleSave}
onkeydown={handleNotesKeydown}
placeholder={t("notes.placeholder")} placeholder={t("notes.placeholder")}
></textarea> ></textarea>
{/if} {/if}
@@ -610,6 +762,21 @@
background: var(--border); background: var(--border);
outline: none; outline: none;
} }
.import-strip {
flex: none;
padding: 0 1rem 0.5rem;
}
.engine-meta {
flex: none;
margin: 0;
padding: 0 1rem 0.4rem;
font-size: 0.75rem;
color: var(--muted);
}
.engine-meta strong {
font-weight: 600;
color: var(--fg);
}
.pad { .pad {
padding: 1rem; padding: 1rem;
max-width: 760px; max-width: 760px;
@@ -842,6 +1009,40 @@
.toolbar .spacer { .toolbar .spacer {
flex: 1; flex: 1;
} }
.toolbar .enhance {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
font-weight: 600;
}
.toolbar .enhance:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 12%, var(--bg));
}
.toolbar .enhance:disabled {
opacity: 0.6;
cursor: default;
}
.enhance-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.enhance-bar.error {
color: var(--danger);
}
.enhance-bar .link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
.editor-preview { .editor-preview {
height: calc(100% - 2.5rem); height: calc(100% - 2.5rem);