Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f300ee177 | ||
|
|
52d3fcbf3b | ||
|
|
2181aa5b0e | ||
|
|
4e87cab3ad | ||
|
|
84ab88b565 | ||
|
|
ae13944563 | ||
|
|
8fc5d72576 | ||
|
|
681933c8cf | ||
|
|
cfe73e7d80 | ||
|
|
505387ba49 | ||
|
|
fa8ce62f63 | ||
|
|
7527da92c5 | ||
|
|
ef933c3786 | ||
|
|
f6f336d79f | ||
|
|
1f25833e74 | ||
|
|
0d8bcf3b17 | ||
|
|
27a1f98051 | ||
|
|
0ecd6db12e | ||
|
|
d2f35c64b1 | ||
|
|
5967fd6997 | ||
|
|
a0ad1564a8 | ||
|
|
377a91d1b8 | ||
|
|
7796c5b62d | ||
|
|
698fb26b0a | ||
|
|
802e30e9c6 | ||
|
|
f1683af01c | ||
|
|
12388b8bd9 | ||
|
|
b295e1ee83 | ||
|
|
c98c14bbc7 | ||
|
|
c0b7587298 | ||
|
|
d5f0d028ac | ||
|
|
2f925de9a5 | ||
|
|
486767587e | ||
|
|
88969019b3 | ||
|
|
2b13de4ef2 | ||
|
|
a112b81d6e | ||
|
|
1da127a61c | ||
|
|
57d3aca386 | ||
|
|
aac88bfbc5 | ||
|
|
e5fdb2baea | ||
|
|
fc89f76ac8 | ||
|
|
7b8a651730 | ||
|
|
e1e2808d9c | ||
|
|
77234e697d | ||
|
|
f5c521df0d | ||
|
|
3f04ec6167 | ||
|
|
030b06329a | ||
|
|
a7af623753 | ||
|
|
b98c258c13 | ||
|
|
8ca6cf4f89 | ||
|
|
dbe845e923 | ||
|
|
d5aef2966f | ||
|
|
2389fb05a5 | ||
|
|
96d02cc220 | ||
|
|
c3da6cf0f3 | ||
|
|
5564c9c9a6 | ||
|
|
d25c297257 | ||
|
|
23cfca7ea2 | ||
|
|
6129866ac5 | ||
|
|
2e57ccfab5 | ||
|
|
4dff7a58b5 | ||
|
|
25204c0235 | ||
|
|
2b2e120f12 | ||
|
|
70eb182eb4 | ||
|
|
836547596b | ||
|
|
58215076cb | ||
|
|
862ab86860 | ||
|
|
a0faaa94b4 | ||
|
|
9c74f84c5b | ||
|
|
57cd46be46 | ||
|
|
490a960676 | ||
|
|
379bdf532f | ||
|
|
95f07e1dba | ||
|
|
931a2b78b4 | ||
|
|
89254bc070 | ||
|
|
22a37c5f4e | ||
|
|
cecfe20eec | ||
|
|
6fbf967fdb | ||
|
|
9f3c1b65d0 | ||
|
|
a4b2b9a292 | ||
|
|
12e23143f6 | ||
|
|
fd556c57a1 | ||
|
|
1ea8011dc8 | ||
|
|
002f8caa2b | ||
|
|
d72509df5f | ||
|
|
7ef45bb315 | ||
|
|
bd251b991a | ||
|
|
dfaeb7c873 | ||
|
|
6b164cd02a | ||
|
|
21101054a4 | ||
|
|
16200a130e | ||
|
|
86f115642c | ||
|
|
1760d4ae24 | ||
|
|
2681a5d8c8 | ||
|
|
233d641041 | ||
|
|
29589663b2 | ||
|
|
28058e205d | ||
|
|
235129d0d6 | ||
|
|
59c2d70643 | ||
|
|
e082f99f32 | ||
|
|
5260a4240b | ||
|
|
bf644ea0be | ||
|
|
13fb9984da | ||
|
|
99d50583cf | ||
|
|
0027becd10 | ||
|
|
e3c80b565c | ||
|
|
2a637e78ad | ||
|
|
80ad2a5f76 | ||
|
|
6622ae81ff | ||
|
|
90b2d51d08 | ||
|
|
7081040475 | ||
|
|
7dfcbdf247 | ||
|
|
864cd33a5f | ||
|
|
94eafd4fae | ||
|
|
45ddc2bd3e | ||
|
|
bb226dcb5e | ||
|
|
98b183d2b0 | ||
|
|
8828fca805 | ||
|
|
9057b25e92 | ||
|
|
41b5553836 | ||
|
|
538db4372f | ||
|
|
8906f8c8ed | ||
|
|
2f62e4b13a | ||
|
|
3214bd2740 | ||
|
|
e7b951cb90 | ||
|
|
a341afc52f | ||
|
|
83babc99cd | ||
|
|
4015b5132e | ||
|
|
b5f5ef172c | ||
|
|
69eedc9354 | ||
|
|
c9b5b91473 | ||
|
|
dca797aaa3 | ||
|
|
b532acbb3c | ||
|
|
469df7524f | ||
|
|
ec864c24f0 | ||
|
|
9f1fc671da | ||
|
|
3372dd7490 | ||
|
|
a32acba157 | ||
|
|
d9f4fd0960 | ||
|
|
ff74e91f69 | ||
|
|
e557d7599f | ||
|
|
678f8bee0f | ||
|
|
1cf0be1259 | ||
|
|
b14cba2a49 | ||
|
|
7df76dff00 | ||
|
|
a48550da1e | ||
|
|
ce4c54c93b | ||
|
|
aa5ae127b3 | ||
|
|
3d90265e8f | ||
|
|
d81b4726f9 | ||
|
|
ef6bb28f3e | ||
|
|
d407be8bb0 | ||
|
|
8a80580851 | ||
|
|
83cb30ef0e | ||
|
|
6991c1d1bc | ||
|
|
40709b257d | ||
|
|
4e33e7d84c | ||
|
|
e7cc433aff | ||
|
|
2cc48c9d4d | ||
|
|
be025bbd6c | ||
|
|
7ebd34ae08 | ||
|
|
353d582e96 | ||
|
|
7bff39271c | ||
|
|
e6e995efe0 | ||
|
|
2cdae9b20d | ||
|
|
dc969df792 | ||
|
|
489271964f | ||
|
|
4c00496c09 | ||
|
|
7490ff5db4 | ||
|
|
34fff3f7de | ||
|
|
7675e0467e | ||
|
|
75ca3df0a0 | ||
|
|
66d9eff2f9 | ||
|
|
1fb1da0946 | ||
|
|
496a8aa29b | ||
|
|
7f88101a79 | ||
|
|
37a6dd172d | ||
|
|
6bc0949e7e | ||
|
|
e966181ab8 | ||
|
|
56e739c6fe | ||
|
|
a68407f059 | ||
|
|
67fffb0203 | ||
|
|
a211f88ad4 | ||
|
|
b986c570f7 | ||
|
|
0a597f86f0 | ||
|
|
754f0f0b6a | ||
|
|
3cdb0abb2d | ||
|
|
11b175dafa | ||
|
|
d73af49742 | ||
|
|
e7925fc3ad | ||
|
|
c3e8bae27f | ||
|
|
2582d8947f | ||
|
|
ff3ced6874 | ||
|
|
57e757f01f | ||
|
|
f76cd46660 | ||
|
|
10edf053a9 | ||
|
|
23388f9305 | ||
|
|
42463d41f6 | ||
|
|
f5651c1133 | ||
|
|
0839d9c0b8 | ||
|
|
71824d44cc | ||
|
|
e424ecd8d3 | ||
|
|
1ade08a60e | ||
|
|
02fb7de6a8 | ||
|
|
70b4952366 | ||
|
|
ae0ef563e9 | ||
|
|
ec8b12f636 | ||
|
|
cd6997265f | ||
|
|
63bfc293cb | ||
|
|
4fc3df504a | ||
|
|
55b520d373 | ||
|
|
bd97032550 | ||
|
|
59742e4fdf | ||
|
|
8caeb818a7 | ||
|
|
71c4c6ecf0 | ||
|
|
386be75658 | ||
|
|
36a28dc096 | ||
|
|
07072551c0 | ||
|
|
e0112ec8c4 | ||
|
|
45e34284e9 | ||
|
|
0ecc72f18f | ||
|
|
f99845ef1b | ||
|
|
f85b78c9eb | ||
|
|
949bade137 | ||
|
|
eb2aa2a0d6 | ||
|
|
49cc1e6cd3 | ||
|
|
bd9b5ba3cc | ||
|
|
58d565907b | ||
|
|
5f690f4b74 | ||
|
|
851e89720d | ||
|
|
9eed88e5bd | ||
|
|
c48c67760d | ||
|
|
14868d4e5c | ||
|
|
fc93f8e14f | ||
|
|
c73245f293 | ||
|
|
e0668e48df | ||
|
|
999084ab3f | ||
|
|
c88c04233c | ||
|
|
7e1b7882eb | ||
|
|
18f46ea6c3 | ||
|
|
a7fda95843 | ||
|
|
d14a6766e5 | ||
|
|
3038b9d05d | ||
|
|
b40a7e2fbc | ||
|
|
106ffed786 | ||
|
|
23db4b6dac | ||
|
|
4cf5eb89b4 | ||
|
|
246fb60731 | ||
|
|
233c6fa8cd | ||
|
|
2eff32662b | ||
|
|
f0480244bf | ||
|
|
2a0379ce43 | ||
|
|
91677476cc | ||
|
|
76a4f8ce08 | ||
|
|
2603e12b36 | ||
|
|
90ba14cc2d | ||
|
|
7106dc1584 | ||
|
|
84b9087de7 | ||
|
|
1634820523 | ||
|
|
57127cfa07 | ||
|
|
49893d5874 | ||
|
|
a14ca871e1 | ||
|
|
2fda4041ff | ||
|
|
0ecba7f055 | ||
|
|
ea79fb656d | ||
|
|
a7eba7fd82 | ||
|
|
d08d03077c | ||
|
|
3b0f27f5d9 | ||
|
|
f2db826e8c | ||
|
|
a5d81bd9e4 | ||
|
|
010941e620 | ||
|
|
90580da0af | ||
|
|
e715c09ce0 | ||
|
|
da25ccaec6 | ||
|
|
e7f628ad7a | ||
|
|
25d0232256 | ||
|
|
34b4f4ae09 | ||
|
|
96b30ed99a | ||
|
|
a37f766817 | ||
|
|
4cdeb8f475 | ||
|
|
5828023651 | ||
|
|
3202f884af | ||
|
|
996c7edc99 | ||
|
|
bb688418bf | ||
|
|
97b5f67049 | ||
|
|
55ddaad544 | ||
|
|
75f7d19ade | ||
|
|
2fb5fd6f30 | ||
|
|
b8dc5fc37f | ||
|
|
38b9cb2910 | ||
|
|
137605934c | ||
|
|
5b281531fb | ||
|
|
2759a0e49a | ||
|
|
8e3e22c0e5 | ||
|
|
bb1d1730f8 | ||
|
|
16fcb154f3 | ||
|
|
cf4232cbae | ||
|
|
4a30674008 | ||
|
|
e16d9e5ade | ||
|
|
1a31cac65b | ||
|
|
ba1b577734 | ||
|
|
fb406d62da | ||
|
|
68c32ffa06 | ||
|
|
2efe29558a | ||
|
|
3cd267f737 | ||
|
|
f9e75b7d17 | ||
|
|
ae9e8398c5 | ||
|
|
4f80c14f72 | ||
|
|
e68e888c07 | ||
|
|
7e646b3e0f | ||
|
|
00de9d3f68 | ||
|
|
e6a2109c9c | ||
|
|
67ac4c3813 | ||
|
|
9d757be415 | ||
|
|
871b11d75e | ||
|
|
75ec24ddf6 | ||
|
|
4637ce8aac | ||
|
|
6f07f28cef | ||
|
|
fa8a7f2552 | ||
|
|
8f98332cdd | ||
|
|
2ca4151444 | ||
|
|
66aae232c5 | ||
|
|
43497ca8cd | ||
|
|
6a4b832628 | ||
|
|
33c0397de8 | ||
|
|
9c670f9b6b | ||
|
|
8f6e8041d6 | ||
|
|
fb790091fd | ||
|
|
496a3372c4 | ||
|
|
45b2acbbc6 | ||
|
|
149f2c995d |
@@ -41,3 +41,8 @@ Thumbs.db
|
||||
# NPU runtime bundle: a large binary artifact hosted as a Gitea package, not
|
||||
# committed. The folder's README is tracked; the zip is produced locally.
|
||||
packaging/npu-runtime/*.zip
|
||||
|
||||
# Vulkan loader staged next to the exe / into src-tauri by build.rs for the
|
||||
# --features vulkan build (bundled into the installer); a redistributable blob,
|
||||
# not committed.
|
||||
src-tauri/vulkan-1.dll
|
||||
|
||||
@@ -21,6 +21,13 @@ Every memory operation in this session goes through MEMANTO. There is no excepti
|
||||
|
||||
These are not suggestions. Follow each one on every turn.
|
||||
|
||||
0. **Activate the `whispassist` agent at the start of every session, before any memory op.** Run
|
||||
`memanto agent activate whispassist` first thing. This machine hosts multiple projects and the
|
||||
session-start sync may activate a *different* project's agent (e.g. `whispassist`), so the
|
||||
auto-synced `MEMORY.md` can belong to the wrong project — do not trust it as LastERP context
|
||||
until you've activated `whispassist` and re-synced. Confirm with `memanto agent list` (the
|
||||
active one is marked). All `recall`/`remember`/`answer` calls read and write the *active*
|
||||
agent's store, so getting this wrong silently pollutes or mis-reads another project's memory.
|
||||
1. **Read `MEMORY.md` before doing anything.** It is auto-synced at session start and holds
|
||||
the user's preferences, facts, goals, instructions, decisions, and commitments from every
|
||||
prior session. You MUST honor what is written there. If you act against it, you are
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
# Memory — whispassist
|
||||
|
||||
> Generated: 2026-07-07 21:30:45
|
||||
> Total memories: **75**
|
||||
> Breakdown: fact: 3, decision: 10, goal: 1, preference: 1, context: 3, event: 1, learning: 24, observation: 2, artifact: 25, error: 5
|
||||
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
*Standing rules, constraints, and guidelines to always follow.*
|
||||
|
||||
*No memories of this type.*
|
||||
|
||||
---
|
||||
|
||||
## Facts
|
||||
|
||||
*Verified information, project status, and established truths.*
|
||||
|
||||
### parakeet-rs (altunenes) DOES have a genuine increm...
|
||||
|
||||
parakeet-rs (altunenes) DOES have a genuine incremental streaming decode API for Parakeet-family models: ParakeetEOU and Nemotron structs thread real recurrent state between calls internally (EncoderCache: cache_last_channel/cache_last_time/cache_last_channel_len; decoder LSTM state_h/state_c; last_token) plus a 4s rolling audio ring buffer, so callers just feed sequential small chunks (160ms for EOU, 560ms for Nemotron) and get incremental partial text -- it is not naive re-chunking of a batch decoder. Source: github.com/altunenes/parakeet-rs src/parakeet_eou.rs and model_eou.rs, examples/streaming.rs (checked 2026-07-02).
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T20:09:27*
|
||||
|
||||
### WhispAssist app icon/branding: real logo provided ...
|
||||
|
||||
WhispAssist app icon/branding: real logo provided by user (paperclip mascot + purple speech-bubble-with-sparkles mark, WhispAssist wordmark). The app icon (title bar/taskbar/tray/installer) is cropped from just the small speech-bubble-with-sparkles mark, not the full marketing graphic or the paperclip mascot (too much fine detail to read at 16-32px). Updated twice: first a transparent-background crop, then a refined version on its own gradient purple background from a cleaner logo revision the user provided. Regenerated via ; that command generates iOS/Android/Appx/macOS outputs by default which must be deleted since WhispAssist is Windows-only (not referenced by tauri.conf.json). tray.png is a manual 32x32 export, not one of tauri icon's own output names.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T20:13:49*
|
||||
|
||||
### The EOU streaming variant used by parakeet-rs is a...
|
||||
|
||||
The EOU streaming variant used by parakeet-rs is a genuinely separate ONNX export, not a mode of the batch Parakeet TDT model: it's NVIDIA's own nvidia/parakeet_realtime_eou_120m-v1 (120M params, cache-aware FastConformer encoder + LSTM decoder, 80-160ms chunks, English-only, no punctuation/casing, emits <EOU> token). This is distinct from istupakov/parakeet-tdt-0.6b-v3-onnx (600M, the community ONNX conversion used for WhispAssist's originally-considered batch/full-file transcription path, which has the ~4-5min length limit). Both are downloaded separately; author's own code comment on reset_on_eou says 'I must admit that this is not work very well on my real world tests'.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:09:30*
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
*Architectural choices, approach selections, and their rationale.*
|
||||
|
||||
### M4.4 (MS Graph calendar source, T8.9, FR-CAL-6) SH...
|
||||
|
||||
M4.4 (MS Graph calendar source, T8.9, FR-CAL-6) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (commits 83cb30e..7df76df + 1cf0be1). This LIFTS the 2026-07-02 'T8.9 on hold' decision — user explicitly asked to build it in this session, overriding the earlier hold. Implementation: calendar::GraphSource implementing CalendarSource, reusing sync::oauth's provider-agnostic PKCE/loopback machinery (added a 'graph-calendar' OAuth provider entry, made sync::resolve_access_token pub(crate) for cross-module reuse) rather than building new OAuth plumbing. This completes all of milestone M4 (M4.1 chunked upload, M4.2 multi-language, M4.3 Dropbox/Box, M4.4 Graph calendar).
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-08T02:22:48*
|
||||
|
||||
### WhispAssist roadmap: T8.8 (at-rest encryption / va...
|
||||
|
||||
WhispAssist roadmap: T8.8 (at-rest encryption / vault) and T8.9 (Microsoft Graph calendar) are ON HOLD per user decision (2026-07-02). Consequence: Phase 9c (T9.12 client-side encryption before upload) is blocked since it depends on the T8.8 vault - skip 9c for now. Building Phase 9 (remote sync) starting with 9a (WebDAV).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T01:01:49*
|
||||
|
||||
### WhispAssist GPU acceleration decision (2026-07): w...
|
||||
|
||||
WhispAssist GPU acceleration decision (2026-07): whisper.cpp GPU transcription via VULKAN as primary cross-vendor path — ONE binary covers NVIDIA+AMD+Intel (whisper-rs 'vulkan' feature). whisper-rs GPU features: cuda (NVIDIA-only, CUDA toolkit at build), vulkan (cross-vendor, Vulkan SDK at build + vulkan-1.dll loader shipping with every GPU driver), hipblas (AMD/ROCm LINUX-ONLY, unusable on Windows), metal (Apple). KEY: whisper.cpp GPU backends are COMPILE-TIME/static (cannot download runtime on-demand like the ort load-dynamic NPU path); binary must be built with the feature. Vulkan is default GPU build; CUDA is optional NVIDIA-only turbo variant LATER (user: 'we will do cuda later'). Vulkan degrades to CPU if no GPU present.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:03:34*
|
||||
|
||||
### WhispAssist roadmap: T8.8 (at-rest encryption/vaul...
|
||||
|
||||
WhispAssist roadmap: T8.8 (at-rest encryption/vault) and T8.9 (MS Graph calendar) ON HOLD per user (2026-07-02). Consequence: Phase 9c (T9.12 client-side encryption) blocked (needs T8.8 vault) - skip for now. Building Phase 9 remote sync starting with 9a WebDAV.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T01:02:11*
|
||||
|
||||
### In WhispAssist's calendar module, CalendarSource::...
|
||||
|
||||
In WhispAssist's calendar module, CalendarSource::import() is a synchronous trait method (matches PstSource's blocking subprocess call). GraphSource (MS Graph, async reqwest HTTP) bridges into that sync signature via tauri::async_runtime::block_on inside fetch_events, and callers run the whole import() call inside tauri::async_runtime::spawn_blocking (same pattern commands.rs already used for PstSource's readpst subprocess) — avoids making CalendarSource async just for one source. Kept a separate WA_GRAPH_CALENDAR_BASE_URL env var (distinct from sync's WA_GRAPH_BASE_URL used by OneDriveTarget) so calendar and sync tests never race on the same process-global env var in cargo test.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-08T02:22:55*
|
||||
|
||||
### WhispAssist PST usability fixes (2026-07-06, commi...
|
||||
|
||||
WhispAssist PST usability fixes (2026-07-06, commits 00de9d3/9d757be/75ec24d): (1) calendar path is now remembered in Settings (pst_last_path field) so the user doesn't re-browse every launch; (2) added an opt-in 'auto-sync on launch' checkbox (pst_auto_sync) that re-imports the remembered path once at startup - deliberately a ONE-SHOT pass mirroring the existing sync-job-resume pattern in lib.rs, NOT a periodic timer, because NFR-RES-1 ('no polling timers when idle') is enforced consistently everywhere else in this codebase (every startup task has a comment noting this). User asked about a periodic 'read frequency' option too; I declined to build that specific piece and explained the NFR-RES-1 conflict rather than silently building or silently dropping it.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T20:40:52*
|
||||
|
||||
### Completed a full UI/UX redesign of WhispAssist: se...
|
||||
|
||||
Completed a full UI/UX redesign of WhispAssist: semantic CSS design-token system (light/dark, WCAG AA verified), all emoji/Unicode icons replaced with @lucide/svelte SVG icons, segmented Monitor/Sun/Moon theme toggle defaulting to system preference, custom theme-aware scrollbars. Informed by researching Granola and Meetily's UIs; kept WhispAssist's 3-pane layout since diarization/speaker-naming already beats both competitors.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T20:13:34*
|
||||
|
||||
### WhispAssist M1 feature-briefs decomposition (2026-...
|
||||
|
||||
WhispAssist M1 feature-briefs decomposition (2026-07-06, docs-only, branch feature_chore_bug_004). KEY INSIGHT: M1 is heavily scaffolded already, do NOT re-create — IPC types FeatureBrief/FeatureBriefInfo/ContextExcerpt (models.rs), api.ts bindings createFeatureBrief/listFeatureBriefs/getFeatureBrief/setBriefExposed, the 4 commands registered in lib.rs, DB tables feature_briefs + mcp_access_log (migrations/0003_ai_mcp.sql), the briefs/<id>.json schema (docs/03-data-model.md), and the FeatureBriefBuilder trait (docs/04-api-contracts.md) ALL EXIST; only the 4 command bodies return not_implemented(). REMAINING to build: (1) Store methods insert_feature_brief/list_feature_briefs/get_feature_brief_row/set_brief_exposed; (2) a new non-streaming LlmProvider::complete(system,user)->String primitive (mirrors suggest_tags; impl Ollama+OpenAiCompat, Anthropic later); (3) FeatureBriefBuilder in a new briefs module (strict '## Title/## Problem/## Desired Outcome/## Acceptance Criteria' prompt like RESPONSE_FORMAT_INSTRUCTIONS + parse_brief mirroring parse_summary); (4) the 4 command bodies (add state: State<AppState>, Tauri injects it, api.ts unchanged); (5) UI in SummaryPanel; (6) golden-transcript tests. KEY DESIGN DECISIONS: context_excerpts are VERBATIM transcript substrings (grounding invariant enforced by the golden test), selected by keyword overlap, NOT model paraphrase; the on-disk file is a sealed BriefFile envelope {schema,generated_at,provider,model,...fields,source} indexed by the feature_briefs table (file=truth, row=index); write file+row only AFTER a successful distill (no partial artifacts on LLM failure). Full implementation-ready checklist in docs/05-roadmap.md M1; JSON schema in docs/03-data-model.md; tests in docs/06-test-strategy.md P10.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T04:28:36*
|
||||
|
||||
### Decision: NOT pursuing NVIDIA Parakeet or DirectML...
|
||||
|
||||
Decision: NOT pursuing NVIDIA Parakeet or DirectML/NPU acceleration for WhispAssist transcription near-term, as of 2026-07-02. Researched achetronic/parakeet (Go, Linux-only, dead end) and altunenes/parakeet-rs (Rust, built on ort, has genuine streaming via a separate EOU 120M model with real internally-threaded encoder cache and LSTM decoder state). Reasons not to pursue now: (1) DirectML support for this model family is unproven - zero reports of anyone running it, and there is a live unresolved ONNX Runtime bug (microsoft/onnxruntime issue 19837) producing wrong output on DirectML for the exact LSTM+Einsum op combination these models use; (2) DirectML itself is now in Microsoft maintenance mode, with new NPU/GPU work moving to Windows ML instead, which calls WhispAssist's existing ADR-0004 (ort + DirectML for NPU) into question independent of Parakeet; (3) Parakeet streaming needs a continuous per-meeting state machine fed small sequential chunks, fundamentally incompatible with WhispAssist's current stateless independent 4-second-window architecture - a real rearchitecture, not a swap, and it would lose whisper.cpp's crash-recoverable-per-window property; (4) competitor Meetily does not actually do live Parakeet+hardware-acceleration either - they only use it for offline batch re-transcription. Recommended future path if revisited: CPU-only Parakeet-EOU streaming spike first to validate the rearchitecture and quality tradeoffs (EOU is English-only, no punctuation/capitalization), treat NPU acceleration as a separate track that should probably target Windows ML rather than raw DirectML.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:37*
|
||||
|
||||
### WhispAssist CUDA + AMD-non-Vulkan acceleration PLA...
|
||||
|
||||
WhispAssist CUDA + AMD-non-Vulkan acceleration PLAN (2026-07-05): whisper.cpp bakes in exactly ONE GPU backend at compile time. Universal build=Vulkan (all vendors). NEW: NVIDIA-turbo installer variant = whisper.cpp CUDA (feature already declared Cargo.toml:94) — but a CUDA build has NO Vulkan, so AMD/Intel GPUs in that variant need a non-Vulkan accel path. DECISION (recommended, user was away/didn't confirm): AMD-non-Vulkan = DirectML via the EXISTING ort/ONNX NPU path (OnnxNpuTranscriber generalized to accept EP: OpenVINO-NPU vs DirectML-GPU+device_id). DirectML works on any DX12 GPU, load-dynamic (no new build toolchain), one binary, degrades to CPU. hipBLAS/ROCm DEFERRED (narrow gfx coverage, fragile on Windows, needs 3rd installer). CORE CODE CHANGE: add AccelPath enum {WhisperCuda,WhisperVulkan,WhisperCpu,OnnxOpenVino,OnnxDirectML} + resolver in hardware/mod.rs from (BackendId+cfg!(feature)+runtime readiness); transcriber factory switches on it. This also CLOSES the known detection-honesty gap (GPU marked available from DXGI regardless of compiled feature -> no-op GPU routing). PHASES: P0 detection-honesty+AccelPath (small, no hw, do first), P2 AMD/Intel DirectML (validate on Intel Arc iGPU locally), P1 CUDA (needs NVIDIA hw/CI). ort needs 'directml' feature + an onnxruntime.dll built with DML EP in the runtime bundle; DirectML.dll ships with Win10 1903+.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-05T23:28:58*
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
*Objectives, targets, and milestones to track progress.*
|
||||
|
||||
### WhispAssist NEXT STEPS — CPU transcription slownes...
|
||||
|
||||
WhispAssist NEXT STEPS — CPU transcription slowness testing round (open bug): the full transcribe_file path takes ~90s for a 6.3s clip on a 14-thread CPU with base.en (should be ~2-3s). This is PRE-EXISTING (predates Vulkan) and independent of the GPU work. To root-cause next: (a) verify whisper set_n_threads actually applies available_threads()=14 (log n_threads in run_full); (b) check whether the encoder pays full 1500 audio_ctx per 30s window even for a 6s clip in the non-streaming path (the streaming path fix in commit 8530a22 scaled audio_ctx by window length, but transcribe_file/run_full may not); (c) run stock whisper-cli directly on the same model+wav to isolate whether it's OUR run_full config vs whisper.cpp itself; (d) test greedy vs beam params and q5_1 vs f16 model; (e) confirm it's not thermal/throttle. The non-vulkan CPU baseline re-run (task, ~90s expected) was in progress to formally confirm parity with the vulkan build's CPU number.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:11*
|
||||
|
||||
---
|
||||
|
||||
## Commitments
|
||||
|
||||
*Promises, obligations, and TODOs that need follow-through.*
|
||||
|
||||
*No memories of this type.*
|
||||
|
||||
---
|
||||
|
||||
## Preferences
|
||||
|
||||
*User and entity preferences for personalization.*
|
||||
|
||||
### WhispAssist user working style (observed 2026-07-0...
|
||||
|
||||
WhispAssist user working style (observed 2026-07-06): (1) Demands EMPIRICAL PROOF over theory. When I attributed silent new recordings to the mic-not-in-WAV design, they pushed back ('the old file is also vault-sealed and plays fine, so it must be the 32->16bit change'). Resolving it required an actual real-hardware loopback capture test (play a known sound, read peak i16 from the WAV) to prove the 16-bit path records real audio — only then accept the diagnosis. HOW TO APPLY: when diagnosing a bug, verify the cause with a runnable test/measurement and show the evidence; don't just assert a root cause. (2) Highly protective of encryption-at-rest. They independently spotted that the decrypted audio.play.wav on disk and the browser 'download' button undermined the vault, and asked to switch playback to in-memory on-the-fly decryption. HOW TO APPLY: proactively avoid writing plaintext of vault-sealed data to disk and close off easy exfiltration paths (downloads, temp files).
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-07T01:05:30*
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
*Entity connections, team context, and collaboration patterns.*
|
||||
|
||||
*No memories of this type.*
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
*Session summaries, status updates, and conversation state.*
|
||||
|
||||
### WhispAssist release/versioning process (as of v0.2...
|
||||
|
||||
WhispAssist release/versioning process (as of v0.2.0, 2026-07-06): the version string lives in THREE files that must be bumped together — package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml (Cargo.lock updates on build). The shipped UNIVERSAL installer is built with: npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json, with env VULKAN_SDK=C:\VulkanSDK\1.4.350.0, CMAKE_GENERATOR=Ninja, CARGO_TARGET_DIR=C:\wt, and vcvars64.bat loaded from 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat'. Outputs land at C:\wt\release\bundle\msi\WhispAssist_<ver>_x64_en-US.msi and \nsis\WhispAssist_<ver>_x64-setup.exe (MSI ~44MB, NSIS ~9MB). The build does NOT create/push git tags — after merging to main, tag manually: git tag v<ver>; git push origin v<ver>. Last release before 0.2.0 was v0.1.6 (PR #15).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T01:05:16*
|
||||
|
||||
### Project status as of 2026-07-02: Phase 8 tasks T8....
|
||||
|
||||
Project status as of 2026-07-02: Phase 8 tasks T8.7 (multi-language transcription + i18n scaffold) and T8.8 (at-rest encryption/vault, needs an ADR decision on SQLCipher vs file-level encryption first) remain deferred/pending. User paused them to do a full UI/UX redesign, then a transcription-latency investigation and fix, then Parakeet/NPU research. Both T8.7 and T8.8 are still the next planned work whenever the user returns to the Phase 8 roadmap.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:43*
|
||||
|
||||
### WhispAssist remaining-work assessment + plan (2026...
|
||||
|
||||
WhispAssist remaining-work assessment + plan (2026-07-06, docs-only planning on branch feature_chore_bug_004, NOT built). As of v0.2.0, Phases 1-9 ship. The only real gap is Phase 10 (external AI + agent handoff, ADR-0011) plus a few polish items. STUBBED (commands return Err(not_implemented)): create/list/get_feature_brief, set_brief_exposed, mcp_status, set_mcp_enabled, set_mcp_scope, mcp_access_log, run_agent, create_issue_from_brief. SKELETON/ABSENT: mcp/mod.rs (trait + todo!() only), AnthropicProvider (returns 'not built yet'), MS Graph CalendarSource, multi-language transcription, DropboxTarget/BoxTarget (OAuth wired in sync/oauth.rs but no upload impl), chunked/resumable upload. BUILT already: OpenAiCompatProvider. The phased plan lives in docs/05-roadmap.md section 'Remaining work — post-v0.2.0 execution plan': M1 feature briefs (first; LLM-only, no egress) -> M2 MCP server (the differentiator; inbound loopback, zero added egress, FR-MCP-7 egress-unchanged is the merge gate) ; M3 hosted AI (parallel; finish Anthropic + hosted-key/banner/allowlist) ; M4 reliability/breadth (chunked upload is TOP item because recordings are now ~50-100MB and put() buffers whole file, OneDrive/Graph caps PUT at 250MB; then multi-language, Dropbox/Box) ; M5 push handoff (agent runner + issue tracker, Could-priority, last/optional). MS Graph calendar deferred (Could).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T04:28:28*
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
*Important conversations, milestones, and temporal occurrences.*
|
||||
|
||||
### WhispAssist reached v0.2.0 (2026-07-06), supersedi...
|
||||
|
||||
WhispAssist reached v0.2.0 (2026-07-06), superseding v0.1.6. Additions since 0.1.6: microphone capture (records+transcribes the user's voice, mixed into transcript AND the saved recording at native quality via MicBridge), in-app recording playback with in-memory on-the-fly decryption (waaudio:// custom protocol, no plaintext on disk, download disabled), AI-generated tags + chip tag editor + tag filtering, meeting rename (editable title), notes single-pane Editor/Preview toggle, cancel-a-recording, 16-bit half-size recordings, live per-item sync upload progress, audio output + microphone device pickers, Outlook .pst recurring-event import/filtering + auto-sync-on-launch, and a SINGLE universal Vulkan installer (bundled vulkan-1.dll) replacing the separate CPU/NPU vs Vulkan builds. Built on branch feature_chore_bug_003 — still needs merge to main + tag v0.2.0. Installers already produced at C:\wt\release\bundle.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T01:05:36*
|
||||
|
||||
---
|
||||
|
||||
## Learnings
|
||||
|
||||
*Knowledge acquired from experience, corrections, and insights.*
|
||||
|
||||
### Real libpst quirk found via a real 7.2GB mailbox t...
|
||||
|
||||
Real libpst quirk found via a real 7.2GB mailbox test (2026-07-06): this readpst/libpst build joins a multi-value RRULE BYDAY with semicolons instead of RFC 5545's commas, e.g. 'RRULE:FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR' - so TU/WE/TH/FR appear as bare semicolon-separated tokens with no '='. A naive RRULE parser using '?' on split_once('=') silently bails out (returns None) for any event with more than one weekday, which is why the first version of expand_rrule worked for single-BYDAY series but silently dropped recurrence for multi-weekday ones. Fix: track the last-seen key and attribute a bare (no '=') token to it as a continuation value. This joins other already-documented libpst 0.6.63 quirks in ADR-0008 (no ORGANIZER/ATTENDEE emitted, no -8 flag support, wrong -t usage string).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T20:40:50*
|
||||
|
||||
### WhispAssist WASAPI microphone finding (2026-07-06)...
|
||||
|
||||
WhispAssist WASAPI microphone finding (2026-07-06): unlike get_default_device(Render) which FAILS in cargo test on this dev machine, capturing the DEFAULT MIC (Direction::Capture) DOES work under cargo test AND delivers real frames (~3840 16kHz frames in 0.6s). Caveat: the FIRST COM activation of the mic can deliver 0 frames within the first ~600ms (cold start); a second run delivers normally. So a hardware mic smoke test should assert open+stop succeed (summary.sample_rate>0), not frames>0. Test: audio::tests::microphone_capture_opens_and_stops_cleanly (#[ignore], run with --ignored).
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T21:34:39*
|
||||
|
||||
### CONFIRMED FIX for WhispAssist release build crashe...
|
||||
|
||||
CONFIRMED FIX for WhispAssist release build crashes: Windows Defender real-time scanning of src-tauri/target was corrupting rustc.exe's compilation (random STATUS_STACK_BUFFER_OVERRUN crashes on different crates each run). Adding Defender exclusions (Add-MpPreference -ExclusionPath for target/, ~/.cargo, ~/.rustup, and -ExclusionProcess for rustc.exe/cargo.exe) fixed it completely -- full release build (whisper-rs, sherpa-rs native deps, MSI+NSIS bundling) now succeeds cleanly with the project's normal aggressive release profile (opt-level=z, codegen-units=1, lto=true). No toolchain reinstall or profile change was needed after all; those were red herrings from earlier in the debugging session. Also: a leftover running whispassist.exe instance can block cargo from overwriting the binary with 'Access is denied' -- close it before rebuilding.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-01T21:09:24*
|
||||
|
||||
### Dark-mode bug pattern learned: CSS custom properti...
|
||||
|
||||
Dark-mode bug pattern learned: CSS custom properties don't cascade upward to ancestor elements. If data-theme (or similar theme attribute) is only set on an inner .app div, html/body keep the browser's default white background + 8px UA-stylesheet margin, invisible in light mode but a bright white border around the whole window in dark mode. Fix: mirror the theme attribute onto document.documentElement via an effect, and add a global html/body margin:0 + background:var(--bg) rule.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:13:41*
|
||||
|
||||
### WhispAssist NPU spike (T3.4): Intel AI Boost NPU (...
|
||||
|
||||
WhispAssist NPU spike (T3.4): Intel AI Boost NPU (Core Ultra 5 135U, Meteor Lake, PCI VEN_8086&DEV_7D1D) runs the Whisper base.en ONNX encoder via ONNX Runtime OpenVINO EP (device_type=NPU) at ~66 ms/window vs ~236 ms/window on CPU = 3.58x faster, correct output shape (1,1500,512), full op coverage. Validates the plan: offload the fixed-shape Whisper encoder to the NPU, keep the dynamic decoder on CPU.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T20:31:08*
|
||||
|
||||
### WhispAssist: an OAuth-linked calendar source (MS G...
|
||||
|
||||
WhispAssist: an OAuth-linked calendar source (MS Graph) is stored in Settings (settings.json: graph_calendar_enabled + graph_calendar_credential_ref) rather than as a sync_targets DB row, because it is not a SyncTarget/upload destination — reusing add_sync_target's OAuth flow would have wrongly surfaced it in the Sync UI and risked the upload pump trying to build a SyncTarget for it. begin_graph_calendar_link is a separate command from begin_oauth_link for this reason, duplicating ~60 lines of PKCE handshake rather than sharing it, since the two flows diverge in storage/eventing.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-08T02:23:03*
|
||||
|
||||
### cargo fmt -- <specific files> does not scope forma...
|
||||
|
||||
cargo fmt -- <specific files> does not scope formatting to those files in the WhispAssist repo (src-tauri) — it reformats the entire crate regardless of file args passed after --, pulling in unrelated pre-existing drift in untouched files (observed: src/audio/mod.rs). After running cargo fmt scoped to touched files, always git status/diff to catch and git checkout -- any unrelated files it touched before committing. Also: memanto's on-prem backend (localhost:8080) needs Ollama (localhost:11434, embedding model nomic-embed-text) running for recall/export/sync to work, and the active agent session (memanto agent activate whispassist) can expire/drop mid-session — 'remember' can report success even when the write doesn't actually persist/index, so verify with 'memanto recall --recent' after a batch of remember calls rather than trusting the success message alone.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-08T02:23:10*
|
||||
|
||||
### WhispAssist runtime bundle URLs CORRECTED to gitea...
|
||||
|
||||
WhispAssist runtime bundle URLs CORRECTED to gitea /media/ path (2026-07-06, commit 224aaf9): the .7z runtime bundles are hosted via Git LFS, and gitea's /raw/ endpoint returns the LFS POINTER (text) not the file, so the download URLs were changed from /raw/branch/main/ to /media/branch/main/ (gitea's media endpoint resolves LFS objects). Final URLs: NPU=https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/openvino.7z, DirectML=https://git.dou.bet/iamdoubz/WhispAssist/media/branch/main/runtime/directml.7z. SHAs unchanged (openvino ca0be9fc..., directml 34369222...). Files tracked via Git LFS (.gitattributes: runtime/*.7z filter=lfs). Still on branch chore_debug (no PR yet); URLs point at main so they resolve after merge. GOTCHA for future: GitHub raw.githubusercontent AND gitea /raw/ both serve LFS pointers not content — always use gitea /media/ (or GitHub media/LFS URL) for LFS-backed download targets.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T14:45:20*
|
||||
|
||||
### cargo test in WhispAssist src-tauri reliably crash...
|
||||
|
||||
cargo test in WhispAssist src-tauri reliably crashes at LINK time (rustc.exe exit 0xc0000409 STATUS_STACK_BUFFER_OVERRUN) when linking the full debug test binary against whisper.cpp+sherpa-onnx native libs with full debug info (debuginfo=2 default for test profile). This reproduces even from a clean target/debug, with reduced --jobs, regardless of Defender exclusions (which fixed the earlier release-build crashes but not this). FIX: set env var CARGO_PROFILE_TEST_DEBUG=0 (drop debug info) before cargo test -- this shrinks the PDB/link footprint enough to avoid the crash. Confirmed working: 'cargo test privacy_self_check' passed cleanly with CARGO_PROFILE_TEST_DEBUG=0 --jobs 4. Also noted: commands.rs is NOT feature-gated for cpu-transcription/diarization (unconditionally imports SherpaDiarizer/WhisperTranscriber/run_streaming_worker), so cargo test --no-default-features fails to compile -- can't lighten the native-link footprint that way, must use the debug-info trick instead.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-01T22:01:31*
|
||||
|
||||
### WhispAssist dark-theme native-control gotcha (2026...
|
||||
|
||||
WhispAssist dark-theme native-control gotcha (2026-07-06, commits 4442f80/8038918): the app theme is a MANUAL toggle (data-theme on html + .app), independent of the OS prefers-color-scheme. Native form controls default to LIGHT and render bright-white in dark mode. Fixes applied: (1) added CSS 'color-scheme: light' on :global(:root) and 'color-scheme: dark' on :global([data-theme="dark"]) in App.svelte — this alone fixed unstyled controls. (2) A <textarea> was white because Settings.svelte's 'input, select { background:var(--bg); color:var(--fg) }' rule EXCLUDED textarea; fix = add textarea to that selector. (3) The header template <select> (.theme-select) options popup stayed WHITE even with color-scheme:dark because it had 'background: transparent' — an AUTHOR-styled select makes Chromium/WebView2 render its options popup in light unless the options carry their own colors. Fix = give .theme-select an explicit 'background: var(--bg-elevated)' AND style '.theme-select option { background: var(--bg-elevated); color: var(--fg) }'. LESSON: for dark-mode selects, set color-scheme on the root AND author the <option> background/color with theme tokens; don't rely on transparent backgrounds.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T13:02:41*
|
||||
|
||||
### WhispAssist test-environment quirk found 2026-07-0...
|
||||
|
||||
WhispAssist test-environment quirk found 2026-07-06: wasapi::get_default_device(&Direction::Render) (and thus find_render_device(None)) FAILS when called from within 'cargo test' on this dev machine, even though the real desktop app (running as a normal foreground GUI process) resolves it fine. Enumeration itself (DeviceCollection) works fine in both contexts -- it's specifically the 'default device' role query that needs a real interactive audio session. Tests for this were written to assert consistency (find_render_device(None) vs a raw wasapi::get_default_device call, or unknown-id fallback vs None) rather than assuming a default device is resolvable, so they pass in both environments. Implication: don't trust 'cargo test' alone to validate anything touching wasapi's default-device APIs -- verify audio-device-selection behavior via the actual running app instead.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T21:02:54*
|
||||
|
||||
### WhispAssist NPU Rust recipe (T3.4, validated): ort...
|
||||
|
||||
WhispAssist NPU Rust recipe (T3.4, validated): ort crate v2.0.0-rc.10 with features [load-dynamic, openvino] links against Intel's pip onnxruntime-openvino DLLs and runs the Whisper base.en encoder on the Intel NPU at 65 ms/window (matches Python; CPU is 236ms). Recipe: (1) ORT_DYLIB_PATH -> site-packages/onnxruntime/capi/onnxruntime.dll (the OpenVINO-enabled ORT build); (2) prepend BOTH openvino/libs AND onnxruntime/capi to PATH so dependent DLLs (openvino.dll, onnxruntime_providers_openvino.dll, onnxruntime_providers_shared.dll) resolve; (3) OpenVINOExecutionProvider::default().with_device_type("NPU").build().error_on_failure() to make a failed NPU registration LOUD instead of silently falling back to CPU; (4) Session::run needs &mut session. For shipping, bundle these DLLs with the Tauri app (resource/sidecar) instead of relying on pip. load-dynamic means no C++/OpenVINO build step in cargo.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T20:42:31*
|
||||
|
||||
### DirectML support in parakeet-rs for NVIDIA Parakee...
|
||||
|
||||
DirectML support in parakeet-rs for NVIDIA Parakeet/EOU/Nemotron models is purely theoretical/unvalidated, not a proven working combination. Evidence: (1) parakeet-rs's own Cargo.toml/execution.rs just forwards to ort's generic directml feature with zero model-specific notes -- contrast with its explicit CoreML warning ('CoreML EP currently runs slower than CPU for Sortformer/Parakeet models because the ONNX graphs have dynamic input shapes'); no equivalent DirectML note exists. (2) Searched all 113 issues in altunenes/parakeet-rs GitHub repo: zero mention DirectML. (3) microsoft/onnxruntime issue #19837 (opened 2024, still unresolved as of check) reports DirectML EP producing wrong numeric results on a model containing LSTM+Einsum ops -- root cause never found. (4) Microsoft's own microsoft/DirectML GitHub repo now carries a banner: DirectML is in maintenance/sustained-engineering mode, with new feature development moved to Windows ML (WinML); relevant since WhispAssist ADR-0004 specifies ort+DirectML for NPU accel. Recommend flagging ADR-0004 for review given this shift.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-02T20:09:34*
|
||||
|
||||
### Rebuilding WhispAssist release binary after Phase ...
|
||||
|
||||
Rebuilding WhispAssist release binary after Phase 6: initial 'tauri build' failed with 'only metadata stub found for rlib dependency core' / cannot find crate for std,num_traits (whisper-rs-sys build script, atoi). Root cause: stale/corrupted 19GB target/ dir from a prior interrupted build. Fix: cargo clean in src-tauri, then rebuild clean. Always load vcvars64.bat (VS2022 BuildTools) before cargo/tauri build.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-01T20:27:51*
|
||||
|
||||
### Root cause of WhispAssist release build crashes fo...
|
||||
|
||||
Root cause of WhispAssist release build crashes found: NOT toolchain corruption. rustc.exe crashes with STATUS_STACK_BUFFER_OVERRUN (0xc0000409) specifically compiling the windows-rs crate (v0.61.3) and pxfm crate, reproducible under both rustc 1.94.1 and 1.96.1. Root cause is the project's aggressive release profile in src-tauri/Cargo.toml: opt-level='z' + codegen-units=1 + lto=true triggers an LLVM/rustc codegen crash on these large generated crates. Confirmed fix: overriding just opt-level=2, codegen-units=16 via CARGO_PROFILE_RELEASE_OPT_LEVEL/CARGO_PROFILE_RELEASE_CODEGEN_UNITS env vars lets the windows crate compile cleanly in isolation. Earlier 'toolchain corruption' and 'cargo clean' theories were red herrings -- the missing-std/core-prelude errors seen on other crates were a cascade effect of cargo continuing after the crashed crate's .rlib was never written.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-01T20:40:50*
|
||||
|
||||
### WhispAssist recording fix + diagnosis (2026-07-06,...
|
||||
|
||||
WhispAssist recording fix + diagnosis (2026-07-06, branch feature_chore_bug_003): User reported new recordings had 'no sound' while old ones played. DIAGNOSED: NOT the 32->16bit change. Proved via ignored hardware test loopback_16bit_wav_captures_played_audio (played Windows Alarm01.wav through default device, captured peak i16=11670) that 16-bit loopback capture records real audio fine. Root cause: the recorded WAV was loopback-only, so a mic-only moment (user talking, nothing playing through speakers) recorded as silence while their voice still reached the transcript. FIX (user chose native-quality): added audio::MicBridge (AtomicU32 rate + Mutex<VecDeque<f32>>, cap ~0.5s for clock-drift): loopback thread publishes its rate + pulls mic samples per-frame and mixes into every channel in write_wav_bytes(mic:&[f32]); mic thread resamples its audio to loopback rate (Resampler::new_to) and pushes to the bridge. New WasapiCapture::start_loopback_recording / start_microphone_recording; commands.rs uses them with a shared bridge when mic enabled. Recording is now native rate/stereo 16-bit WITH the user's voice. Verified by ignored test loopback_recording_with_mic_bridge_captures_played_audio (peak i16=22358). This SUPERSEDES the earlier 'mic transcript-only, not in WAV' limitation.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T23:27:12*
|
||||
|
||||
### WhispAssist T3.4 steps 1-3 DONE & validated end-to...
|
||||
|
||||
WhispAssist T3.4 steps 1-3 DONE & validated end-to-end: OnnxNpuTranscriber transcribes speech correctly on the Intel NPU. Pipeline: hand-rolled Whisper log-mel (transcription/mel.rs, matches HF WhisperFeatureExtractor) -> NPU encoder (OpenVINO EP) -> CPU greedy decoder (no KV cache, re-feeds prefix) -> hand-rolled byte-BPE detok from tokenizer.json (no tokenizers crate). Behind cargo feature 'npu' = [dep:ort, dep:rustfft]; renamed from the old empty 'directml' feature. Decode config from generation_config.json: decoder_start=50257, eos=50256, forced_decoder_ids=[[1,50362]] (notimestamps); mask token ids >=50257 (except eot) in argmax. TTS test: spoke 'testing one two three four, the quick brown fox...' got 'testing 1234 the quick brown fox jumps over the lazy dog.' All gates green first try (clippy -D, fmt, 69 default tests, 77 npu tests). NOT YET wired into commands.rs dispatch (that is step 6) — nothing routes to it in-app yet.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T21:14:31*
|
||||
|
||||
### WhispAssist NPU build gotcha: ort's default downlo...
|
||||
|
||||
WhispAssist NPU build gotcha: ort's default download-binaries do NOT include the OpenVINO execution provider. Must supply an ONNX Runtime built with OpenVINO (Intel's prebuilt onnxruntime-openvino) PLUS the OpenVINO runtime DLLs on the DLL path. HARD VERSION PIN: onnxruntime-openvino 1.24.1 requires openvino runtime 2025.4.1 EXACTLY. Version mismatch (e.g. openvino 2026.2) does NOT error loudly — it silently falls back to CPUExecutionProvider (Win Error 127 'procedure could not be found'). Pin the ort<->onnxruntime<->openvino version triple and assert the active provider is OpenVINOExecutionProvider at load, else the NPU is silently unused.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T20:31:10*
|
||||
|
||||
### CONFIRMED ROOT CAUSE (2026-07-02) of the WhispAssi...
|
||||
|
||||
CONFIRMED ROOT CAUSE (2026-07-02) of the WhispAssist whisper.cpp model-load crash: it is Trend Micro Security Agent (Worry-Free Business Security, corporate-managed -- services ntrtscan/TMBMServer/TmCCSF/tmlisten all running) injecting into whispassist.exe and hooking file I/O / MessageBox APIs. Live cdb attach to the process while the 'Debug Assertion Failed: _osfile(fh) & FOPEN' dialog was showing revealed 'tmmon64' (Trend Micro's monitoring module) sitting directly in the call stack between USER32!MessageBoxW and ucrtbased!__acrt_MessageBoxW, and the read path (whisper.cpp's std::ifstream -> xsgetn -> fread) resolves into ucrtbased.dll (debug CRT) even though whisper-rs-sys's CMakeCache.txt confirms /MD (release CRT) was used to build it -- i.e. Trend Micro's hook is corrupting the CRT call path, not a real build misconfiguration. Ruled out first: NOT a stack-size issue (tried 16MiB worker thread stack, crash identical), NOT stale/corrupted build artifacts (crash reproduces identically from a fully clean cargo clean --profile dev rebuild), NOT Windows Defender (already excluded target/ and C:\Users\dadous\AppData\Local\WhispAssist, crash persisted). Fix requires excluding whispassist.exe / the WhispAssist install and model directories from Trend Micro's real-time scan and behavior monitoring -- likely needs corporate IT/policy admin involvement since TMBMServer implies tamper-protected central management, not a self-service local exclusion like Defender. Tooling note: installed WinDbg Preview via 'winget install --id Microsoft.WinDbg' -- ships cdbX64.exe (classic command-line debugger) alongside the modern WinDbgX.exe GUI, usable for live process attach analysis without needing the full Visual Studio IDE debugger.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-02T13:30:42*
|
||||
|
||||
### Fixed a real bug behind reported 30-65 second live...
|
||||
|
||||
Fixed a real bug behind reported 30-65 second live-transcription lag: whisper.cpp's encoder always runs over a full padded 30-second mel window (1500 encoder positions) unless audio_ctx is explicitly reduced via set_audio_ctx. WhispAssist's live-transcription streaming windows are only about 4 seconds each but were never setting audio_ctx, so every window paid the full 30-second-equivalent encode cost, serially, on one worker thread. Fixed in src-tauri/src/transcription/mod.rs (function audio_ctx_for_window) by scaling audio_ctx proportionally to the real window length (1500 positions = 30s, so a 4s window gets about 201). Committed as 8530a22. Verified about 30 percent faster in a controlled A/B benchmark, though that specific test ran under heavy CPU contention from Docker Desktop and other concurrent Claude Code sessions on this machine, which likely masks a larger real-world improvement since the fix targets the encoder O(n^2)-ish attention cost specifically.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:23*
|
||||
|
||||
### WhispAssist dev/test workflow gotchas (2026-07-06)...
|
||||
|
||||
WhispAssist dev/test workflow gotchas (2026-07-06): (1) A running 'npm run tauri dev' / whispassist.exe holds the cargo build lock on its target dir — STOP it (Stop-Process -Name whispassist, plus kill the node/cargo/vite procs whose CommandLine matches tauri|whispassist|vite) BEFORE running cargo build/clippy/test or the build blocks on the lock. (2) 'npm run tauri dev' writes output to the Windows console handle, NOT the redirected background-task log file (which stays empty) — confirm the app actually launched via Get-Process whispassist, not by reading the log; it typically appears ~30s after launch. (3) The audio-device tests find_render_device_none_matches_get_default_device and find_render_device_unknown_id_falls_back_exactly_like_none are FLAKY under cargo test (the wasapi get_default_device(Render) COM quirk) — a rerun passes; do NOT chase them as regressions. Loopback/mic hardware smoke tests (loopback_16bit_wav_captures_played_audio, etc.) are #[ignore]'d and run with --ignored.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-07T01:05:20*
|
||||
|
||||
### WhispAssist tooling FOOTGUN (2026-07-06): 'npm run...
|
||||
|
||||
WhispAssist tooling FOOTGUN (2026-07-06): 'npm run format' = 'prettier --write .' — it reformats the ENTIRE repo, not just changed files. Running it during a small change reformatted 120+ files (all docs/ADRs/.claude skills/CLAUDE.md/stores/etc) because the repo isn't uniformly prettier-clean, burying the real diff. RECOVERY that worked: git diff --name-only | grep out the intended KEEP files | xargs git checkout -- , then verify only intended files remain; the reformats were content+EOL noise (git diff -w showed EOL-only for many). LESSON: to format/verify only your touched files use 'npx prettier --write <files>' or just 'npm run check' (svelte-check) + 'npx eslint <files>' which don't write. ALSO: 'npm run lint' currently reports ~143 PRE-EXISTING errors (mostly 'console'/'process' is not defined no-undef in node-context files) unrelated to app code — don't be alarmed, they predate any given change. Repo has mixed LF/CRLF (git warns 'LF will be replaced by CRLF'); harmless line-ending churn.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T12:48:15*
|
||||
|
||||
### WhispAssist dev-run finding (2026-07-06): 'npm run...
|
||||
|
||||
WhispAssist dev-run finding (2026-07-06): 'npm run tauri dev' builds the Rust with features audio,cpu-transcription,diarization,pst,sync,npu = the DEFAULT set MINUS vulkan and cuda. Consequence: the everyday dev/default build has NEITHER a whisper.cpp GPU backend NOR CUDA, so on the Intel Core Ultra + Arc iGPU dev machine the ONLY GPU accel path is DirectML (via the ort/npu feature) — and directml_would_help() returns TRUE there, so the DirectML Settings card is visible. To exercise the Vulkan path you must build with --features vulkan explicitly (see whispassist-vulkan-build-recipe). Incremental dev rebuild ~42s once whisper.cpp/deps are cached; debug binary at src-tauri/target/debug/whispassist.exe. Launch recipe: load vcvars64.bat (VS2022 BuildTools) then 'npm run tauri dev'; the WebView2 window opens on the user's desktop. Opening Settings does NOT trigger the debug-CRT Abort/Retry/Ignore assertion dialog (that only fires on the transcription file-read path), so a UI-only visual check on the debug build is safe.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T12:27:54*
|
||||
|
||||
### Windows dev-loop gotchas confirmed again this sess...
|
||||
|
||||
Windows dev-loop gotchas confirmed again this session (2026-07-06): (1) Git Bash's quoting of 'cmd.exe /c "...vcvars64.bat" && cargo ...' silently no-ops (just opens/closes an interactive cmd shell) - must run that exact vcvars64.bat wrapper via the PowerShell tool instead, never Bash; (2) both 'cargo fmt' (no path args) and 'npm run format' (prettier --write .) reformat the ENTIRE repo/workspace, not just touched files - this touched unrelated pre-existing files (commands.rs WebDavTarget chain, all of docs/, .claude/skills/, package.json, CLAUDE.md, package-lock.json) and had to be reverted via targeted git checkout, keeping only the intended diff. Going forward: use 'rustfmt --edition 2021 <specific files>' and 'npx prettier --write <specific files>' instead of the whole-repo commands.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T20:40:55*
|
||||
|
||||
---
|
||||
|
||||
## Observations
|
||||
|
||||
*Patterns noticed, behavioral notes, and recurring themes.*
|
||||
|
||||
### WhispAssist transcription benchmark (2026-07, rele...
|
||||
|
||||
WhispAssist transcription benchmark (2026-07, release 0.1.4, SAME 6.3s TTS clip 'Testing 1234 the quick brown fox...', base.en q5_1 model, full transcribe_file path, machine=Intel Core Ultra 14-thread + Arc iGPU): VULKAN on Intel Arc iGPU = load 274ms, infer ~4.5s (fastest, edges out NPU). NPU OpenVINO = load 1355ms, infer ~5.7s. CPU whisper.cpp = load ~190ms, infer 92-99s (PATHOLOGICAL, ~15x SLOWER than real-time). All three produce the correct transcript. CRITICAL FINDING (user-confirmed): CPU was ALREADY ~90s BEFORE Vulkan was added — so the Vulkan build did NOT regress the CPU path; the 0.1.4 Vulkan build is SAFE to ship (CPU fallback unchanged). The ~90s CPU is a PRE-EXISTING bug in the full transcribe_file path, NOT caused by Vulkan. Vulkan and NPU are ~16-20x faster than the broken CPU path. Note the STREAMING path was already fixed earlier (audio_ctx scaling, commit 8530a22); transcribe_file (single_segment=false, 30s-padded seek loop) is the still-slow one.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:09*
|
||||
|
||||
### Meetily (Zackriya-Solutions/meetily), the competit...
|
||||
|
||||
Meetily (Zackriya-Solutions/meetily), the competitor app cited as prior art for Parakeet integration, uses Parakeet only for BATCH/offline transcription via the transcribe-rs crate (built on istupakov's ONNX conversion, per meetily's own README credits) -- not live streaming. Its 'Import & Enhance' feature (post-hoc re-transcription) is the actual use case; no DirectML-specific hardware acceleration for Parakeet is documented in its backend README. A true streaming fork (Nemotron streaming ASR engine) exists only as a community fork (Amitsurya2000/transcribe-rs), not upstream. sherpa-onnx (k2-fsa) also lacks true streaming Parakeet TDT support as of its open issues #2918 and #3573. Checked 2026-07-02, informs WhispAssist NPU/Parakeet research.
|
||||
|
||||
*Confidence: 0.85 | Status: active | Created: 2026-07-02T20:09:37*
|
||||
|
||||
---
|
||||
|
||||
## Artifacts
|
||||
|
||||
*Tool outputs, files, reports, and external references.*
|
||||
|
||||
### M4.1 (chunked/resumable upload, T9.2 refinement, F...
|
||||
|
||||
M4.1 (chunked/resumable upload, T9.2 refinement, FR-SYNC-2/5) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (commits be025bb, 2cc48c9), same day as M4.2/M4.3/M4.4 but a separate prior session. Recordings are now native-quality (~50-100 MB) and the old put() buffered the whole file into memory in one PUT; OneDrive/Graph also caps a single PUT at 250 MB. Fixed by streaming disk-to-network in fixed-size chunks on both sync backends: WebDavTarget::put and OneDriveTarget::put now use tokio::fs::File + BufReader (O(chunk) memory, not O(file size)) for every upload. Files at/below LARGE_FILE_THRESHOLD (8 MiB) still take a single streamed PUT — only large artifacts (.wav recordings) take the chunked path. WebDAV (Nextcloud/ownCloud) implements the chunking-v2 protocol; OneDrive uses Graph's upload-session API. Needed enabling tokio's io-util/net features for AsyncReadExt/AsyncSeekExt/BufReader (separate chore commit be025bb).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-08T02:27:25*
|
||||
|
||||
### WhispAssist calendar/PST recurrence expansion (202...
|
||||
|
||||
WhispAssist calendar/PST recurrence expansion (2026-07-06, commits fa8a7f2/6f07f28): expand_rrule in calendar/mod.rs now parses RRULE (DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/BYMONTH) and expands each recurring PST event into its own stored row keyed by '{uid}@{ymd}' for dedup, instead of only storing the first occurrence. Rewritten on chrono::Local (promoted from transitive to direct dependency) instead of hand-rolled epoch-day math, because the first version had a real DST bug: it kept a fixed UTC time-of-day per occurrence, so a meeting created in winter (CST) drifted an hour once its weekly recurrence crossed into summer (CDT) - e.g. 16:30 UTC showed correctly as 10:30 in January but wrongly as 11:30 in July. Fix: convert dtstart to local wall-clock once, keep hour/min/sec fixed, re-resolve the UTC offset per occurrence date. Tests assert local wall-clock time is identical across all occurrences (would fail under the old code).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T20:40:48*
|
||||
|
||||
### WhispAssist audio-device-selection feature (2026-0...
|
||||
|
||||
WhispAssist audio-device-selection feature (2026-07-06, commits bb1d173/8e3e22c/2759a0e/5b28153/1376059/38b9cb2/b8dc5fc): added Settings > Hardware > 'Audio Devices' picker overriding the default 'Default system audio' WASAPI loopback render device. Backend: AudioCapture::start now takes device_id: Option<&str> (Device::get_id() string) instead of always resolving wasapi::get_default_device(&Direction::Render); new find_render_device() enumerates via wasapi::DeviceCollection and falls back to system default if the configured device is gone (same degrade-gracefully spirit as the existing mid-recording reconnect, which now retries the SAME selection first instead of switching to whatever's currently default). New list_render_devices()/list_audio_devices command (wasapi crate already supported enumeration, just was unused until now). Settings.audio_output_device: Option<String>, #[serde(default)] for backward compat. This is playback/render-device-only (loopback capture) -- WhispAssist has no microphone capture path at all (FR-CAP-1), so there is no separate 'input device' setting.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T21:02:52*
|
||||
|
||||
### WhispAssist M3 (hosted AI providers) SHIPPED (2026...
|
||||
|
||||
WhispAssist M3 (hosted AI providers) SHIPPED (2026-07-07, branch feature_chore_bug_005, merge commit e966181, on top of M1+M2 merge 2582d89). Built by an agent in an isolated git worktree, but that worktree's base snapshot was stale (pre-dated M1/M2 - based on 3038b9d, not the then-current feature_chore_bug_005 HEAD) - a real limitation of this session's worktree isolation mechanism worth remembering: it can silently reuse an earlier repo snapshot rather than the live current branch when a new worktree agent is launched later in the same session. Concretely this meant the M3 agent never saw M1's LlmProvider::complete() trait method addition, so AnthropicProvider::complete() was left as M1's not-yet-implemented stub even though M3 finished summarize/suggest_tags/status for real. Caught this by grepping the worktree for 'fn complete' before merging (found nothing) rather than trusting the agent's done report, then implemented AnthropicProvider::complete_with_key (non-streaming POST to /v1/messages, mirrors suggest_tags_with_key) myself as part of merge reconciliation. Also resolved three merge conflicts: llm/mod.rs (the complete() gap above), and two lucide-icon-import-list conflicts in Settings.svelte/SummaryPanel.svelte (M2 and M3 each added their own icon imports at the same insertion point) - unioned both, verified every icon is actually used before committing. What M3 built: AnthropicProvider real implementation (x-api-key + anthropic-version headers, SSE streaming for summarize, non-streaming for tags/complete), set_llm_provider storing the Anthropic key only in the OS credential store (never settings.json/DB, tested) and adding api.anthropic.com to the egress allowlist only when actually configured, HostedAiBanner.svelte one-time third-party-egress acknowledgment component, active-provider indicator + per-use quick-switch in SummaryPanel. Verified independently end to end on the fully merged M1+M2+M3 tree: cargo test --features mcp = 165 passed/0 failed, clippy clean (default + --features mcp), cargo fmt clean (only the same pre-existing unrelated audio/mod.rs drift as before), svelte-check 0 errors. Lesson for future milestone builds in this repo: after any worktree-isolated agent finishes, grep for the specific trait methods/functions the previous milestone added before trusting 'this builds on M<n-1>' claims - isolation snapshots can silently drift stale mid-session.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T13:16:21*
|
||||
|
||||
### WhispAssist meeting title + tags features (2026-07...
|
||||
|
||||
WhispAssist meeting title + tags features (2026-07-06, commits 2efe295/e68e888 and 68c32ff/16fcb15): (1) meetings previously had NO way to rename from 'Untitled meeting' anywhere in the UI - added an inline-editable title header above the transcript/notes pane (TranscriptNotes.svelte) backed by a new rename_meeting command; (2) attach_meeting_to_event now also mirrors the linked calendar event's subject onto the meeting's title when it has one, so linking 'Jerry / Daniel - Weekly 1:1' auto-renames the meeting; (3) added a 'Generate tags' feature mirroring 'Generate summary' - new LlmProvider::suggest_tags trait method (implemented for Ollama/OpenAI-compatible/Anthropic-stub), reads the transcript via the same build_prompt assembly, non-streamed, returns 1-8 tags merged into (not replacing) the existing tag list; (4) replaced the old comma-separated text-input tags UI with GitHub-topics-style removable/clickable chips (new shared TagChip.svelte component using existing --accent/--accent-soft tokens) - clicking a chip's label calls meetings.filterByTag() which filters the sidebar meeting list, an 'x' removes it.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T20:40:53*
|
||||
|
||||
### WhispAssist Phase 9a increment 1 DONE (branch feat...
|
||||
|
||||
WhispAssist Phase 9a increment 1 DONE (branch feature_another_one): WebDAV target management + connection test, validated end-to-end against a live wsgidav server (file physically uploaded via PROPFIND->MKCOL->PUT->HEAD). Added: storage SyncTargetRow CRUD (sqlx FromRow); sync::WebDavTarget real impl; sync::credentials keyring wrapper (service 'WhispAssist-sync', secret keyed by credential_ref, never in DB); TLS enforcement (enforce_transport: https always, http only for LAN+opt-in); commands list/add/update/remove/test_sync_target + set_sync_enabled (take State now); privacy_self_check wired to real targets. Enabled 'sync' in default cargo features. Anonymous targets supported (basic_auth only sent when a secret exists). All gates green: clippy -D, fmt, 74 tests. Local test server: python -m wsgidav.server.server_cli --host 127.0.0.1 --port 8899 --root <dir> --auth anonymous; test env WA_WEBDAV_URL/USER/PASS, run 'cargo test webdav_round_trip -- --ignored'. STILL TODO increment 2: durable queue + pump/backoff + sync_meeting + finalize-hook enqueue + sync://job events + sync_status/retry_sync_job (currently still not_implemented) + Settings sync UI. Increment 3: 9b OAuth (OneDrive/Dropbox/Box). 9c encryption on hold (needs T8.8 vault).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T01:17:19*
|
||||
|
||||
### M4.2 (multi-language transcription, T8.7, FR-TRX-4...
|
||||
|
||||
M4.2 (multi-language transcription, T8.7, FR-TRX-4) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (large commit chain: 6bc0949 add multilingual/language fields to ModelInfo+Settings, 37a6dd1 multilingual model catalog entries, 7f88101 whisper language catalog for Settings dropdown, 496a8aa wire whisper language param through Transcriber trait, 66d9eff persist requested language at meeting creation, 75ca3df track resolved per-meeting language on RecordingSession, 7490ff5 wire language selection through recording/reprocess/recovery, 34fff3f register list_whisper_languages command, plus a full UI chain (4c00496/4892719/dc969df/e6e995e/7bff392/353d582) for a Settings language picker + per-meeting badge + reprocess override, and a same-day bugfix 40709b2 making resolve_language normalize case-insensitive 'en' matches so the English-only-model guard fires correctly). Delivers: multilingual model option, whisper language param (select/auto-detect), per-meeting language persisted, Settings dropdown + reprocess picker in the UI.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-08T02:27:34*
|
||||
|
||||
### WhispAssist Phase 9a increment 2 DONE: durable upl...
|
||||
|
||||
WhispAssist Phase 9a increment 2 DONE: durable upload queue (sync_jobs CRUD, pump w/ backoff, finalize hook, startup pump, sync://job events). 78 tests green. TODO: Upload-now UI. Increment 3: 9b OAuth; 9c on hold.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-03T02:57:46*
|
||||
|
||||
### WhispAssist in-memory recording playback (2026-07-...
|
||||
|
||||
WhispAssist in-memory recording playback (2026-07-06, branch feature_chore_bug_003): Replaced the file-based player (which decrypted vault-sealed audio.wav to a plaintext audio.play.wav on disk, weakening encryption-at-rest) with an in-memory custom Tauri protocol. commands::serve_recording backs a registered 'waaudio' uri scheme (URL http://waaudio.localhost/<meeting_id> on Windows): reads audio.wav, vault::open decrypts in RAM, streams audio/wav with Range support (parse_byte_range) for seeking; 404 no file, 403 sealed+locked, path-traversal guarded (id must be alnum/hyphen). recording_playback_path now returns that URL after a cheap sealed-prefix + is_unlocked precheck and deletes stale audio.play.wav. commands::cleanup_playback_temp() sweeps all meetings/*/audio.play.wav at startup (called in lib.rs setup). Removed assetProtocol config + tauri protocol-asset feature + convertFileSrc; CSP media-src now 'self' http://waaudio.localhost. UI <audio> has controlsList=nodownload noplaybackrate + oncontextmenu preventDefault so the decrypted audio can't be saved to disk. Verified: clippy clean, svelte-check clean, full build, startup sweep removed leftover play-temp files.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T23:41:52*
|
||||
|
||||
### WhispAssist runtime bundles switched to 7z from re...
|
||||
|
||||
WhispAssist runtime bundles switched to 7z from repo raw URLs (2026-07-06, branch chore_debug, commits 27ba4f9/e6f1916). The NPU (OpenVINO) and DirectML runtimes are now downloaded as .7z (LZMA2+BCJ) from gitea RAW paths: NPU=https://git.dou.bet/iamdoubz/WhispAssist/raw/branch/main/runtime/openvino.7z (SHA ca0be9fc52c78ee623b152f790450b3d4020c5a7ebe99d27736455b308782191), DirectML=https://git.dou.bet/iamdoubz/WhispAssist/raw/branch/main/runtime/directml.7z (SHA 34369222fcc1be2e72a957b868b1976a90150ba704a06c9e8992c34ee368926b). REPLACED the zip crate with sevenz-rust2 (pinned 0.7.0 — newer needs rustc>1.77 MSRV; optional + npu-gated). extract_zip_flat -> extract_7z_flat (uses decompress_file_with_extract_fn, flattens basenames — archives nest DLLs under directml/ and openvino/ folders). stage_directml_runtime now defaults to DIRECTML_RUNTIME_URL const (no longer requires WA_DIRECTML_RUNTIME_URL env). Env overrides WA_NPU_RUNTIME_URL / WA_DIRECTML_RUNTIME_URL still honored. sevenz-rust2 0.7.0 confirmed to decode LZMA2+BCJ (has src/bcj/x86.rs); validated by test extract_7z_flat_unpacks_the_directml_bundle (extracts ../runtime/directml.7z, asserts onnxruntime.dll == 17253408 bytes, flattened) — PASSES. clippy -D warnings clean on default/shipped build; 104+ tests green. NOTE: raw URLs point to branch/main, so they only resolve once runtime/openvino.7z + runtime/directml.7z are committed to MAIN. As of now those .7z files are UNTRACKED (left for the user to commit — plain git add vs Git LFS decision; ~27MB total). The old .zip package-registry URL (git.dou.bet/api/packages/.../npu-runtime/...) is retired. archives were created with 7z LZMA2:24m BCJ.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T13:26:14*
|
||||
|
||||
### WhispAssist M1 (feature briefs) + M2 (MCP server) ...
|
||||
|
||||
WhispAssist M1 (feature briefs) + M2 (MCP server) SHIPPED (2026-07-07, branch feature_chore_bug_005, merge commit 2582d89). Built in parallel by two agents in isolated git worktrees, then merged sequentially (M1 first, ff-merge; M2 second, conflict-merged). M1: Store methods for feature_briefs (insert/list/get_row/set_exposed), LlmProvider::complete() primitive (Ollama+OpenAI-compat), FeatureBriefBuilder distiller (briefs/mod.rs, keyword-overlap grounding), the 4 create/list/get_feature_brief+set_brief_exposed commands, SummaryPanel.svelte UI, golden-transcript tests. M2: mcp module on rmcp (loopback bind + token gate, Streamable HTTP + stdio adapter), 4 tools (list_recent_meetings/get_transcript/get_action_items/get_feature_brief), scope control (none|selected|all), mcp_status/set_mcp_enabled lifecycle (token in OS credential store), disclosure UI + mcp_access_log audit, privacy panel integration. Gated behind an optional mcp Cargo feature. Merge required resolving one real conflict (storage/mod.rs trait+impl interleaving) plus one real cross-branch integration bug: M2's handler.rs called commands::get_feature_brief(id) with the pre-M1 stub arity; fixed by extracting get_feature_brief_core(store, id) shared between the Tauri command and the MCP handler, and wired the previously-stubbed selected-scope exposed-flag check (scope::brief_visible) that M2 had left as a documented KNOWN GAP. Verified independently (not just trusting agent reports): cargo test --features mcp = 154 passed/0 failed, clippy clean (default + --features mcp), cargo fmt clean (only pre-existing unrelated audio/mod.rs drift), svelte-check 0 errors. Known environment gotcha hit repeatedly during verification: whisper-rs-sys native build intermittently fails with MSVC error C1056 'cannot update time date stamp' - Trend Micro AV interference, same class as the previously-recorded model-loading hang; resolved by retrying the build, not a code defect. Also: building in a deeply-nested git worktree path (.claude/worktrees/agent-id/...) can overflow Windows MAX_PATH during whisper.cpp's CMake TryCompile scratch dirs - work around with a short CARGO_TARGET_DIR (e.g. C:/wa-build-x) when testing worktrees directly. M2's one incomplete acceptance item: no compiled end-to-end MCP wire-protocol client test (blocked on an rmcp reqwest-0.13-vs-0.12 version conflict pulling in aws-lc-rs; agent reverted the attempt cleanly rather than leave it unverified) - unit-level loopback-bind-refusal and scope-enforcement tests substitute for now. Next: M3 (hosted AI providers, Anthropic) per user pre-authorization to skip the usage checkpoint and go straight to a scheduled resume.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T06:17:52*
|
||||
|
||||
### M4.3 (Dropbox/Box upload targets, T9.10, FR-SYNC-9...
|
||||
|
||||
M4.3 (Dropbox/Box upload targets, T9.10, FR-SYNC-9) SHIPPED 2026-07-07 on branch feature_chore_bug_005 (commit 6991c1d), same day as M4.1/M4.2/M4.4 but a separate prior session. Implements DropboxTarget and BoxTarget SyncTarget impls (OAuth PKCE was already wired for both providers). DropboxTarget: path-addressed like OneDrive/WebDAV; create_folder_v2 creates the whole intermediate path in one call; upload-session chunking above LARGE_FILE_THRESHOLD (start/append_v2/finish). BoxTarget: Box addresses items by numeric ID not path, so ensure_dir/exists/put all walk (and lazily create) the folder chain from root ('0') by listing each level's children; always uploads via Box's session API regardless of file size (its session API takes plain PUT bodies, consistent with every other target, needs no new reqwest feature, and Box computes/returns each part's digest so no local hashing needed). Both follow the same documented ceiling as OneDriveTarget's put_chunked (M4.1): the upload-session id lives only for one put() call, not persisted across process restarts — a crash mid-chunked-upload restarts that file's upload from scratch rather than resuming (unlike WebDAV's chunking-v2 which is genuinely resumable).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-08T02:27:46*
|
||||
|
||||
### WhispAssist T3.4 NPU acceleration COMPLETE (steps ...
|
||||
|
||||
WhispAssist T3.4 NPU acceleration COMPLETE (steps 1-6, feature_npu branch). Dispatch: commands.rs load_transcriber() routes BackendId::Npu -> OnnxNpuTranscriber (onnx model dir), else whisper.cpp, with graceful CPU fall-through; used by streaming worker and batch reprocess. run_streaming_worker now takes &dyn Transcriber (T: ?Sized). hardware_status returns npu:{present,runtimeReady,modelInstalled}. download_npu_package command + npu://download progress events; startup auto-fetches ONNX model in background if NPU present && model missing. Settings>Hardware shows NPU detected + download indicator. All gates green first try: fmt, clippy default+npu -D warnings, 69 default tests, 77 npu tests, svelte-check 0 errors, eslint. Real NPU inference re-verified post-refactor. KNOWN GAP: OpenVINO runtime staging (stage_npu_runtime) copies DLLs from local dirs in env WA_NPU_RUNTIME_SRC (';'-separated) not a hosted download - no hosted runtime bundle URL yet. Upgrade path: host versioned ORT+OpenVINO bundle, download+unzip into paths::npu_runtime_dir(). Repo frontend NOT prettier-clean (117 files pre-existing); only touched files formatted.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-02T22:17:13*
|
||||
|
||||
### WhispAssist merged single installer (2026-07-06, b...
|
||||
|
||||
WhispAssist merged single installer (2026-07-06, branch feature_chore_bug_003): Combined the two installers (13MB default/NPU-DirectML at src-tauri/target vs 67MB Vulkan at C:\wt) into ONE universal Vulkan installer. Verified facts: default exe 13MB imports no vulkan-1.dll (runs anywhere, GPU via runtime DirectML); Vulkan exe 67MB imports vulkan-1.dll at LOAD time (dumpbin) so it won't launch without the loader. Fix (option A = bundle the loader): build.rs stage_vulkan_loader() runs when CARGO_FEATURE_VULKAN set — copies vulkan-1.dll from %VULKAN_SDK%\Bin (fallback C:\Windows\System32) next to the exe (OUT_DIR ancestors nth(3) = target/<profile>, correct under CARGO_TARGET_DIR=C:\wt) AND into src-tauri/ (gitignored) for bundling. New src-tauri/tauri.vulkan.conf.json overlay adds bundle.resources ['vulkan-1.dll']. Release build cmd: npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json (needs VULKAN_SDK, CMAKE_GENERATOR=Ninja, CARGO_TARGET_DIR=C:\wt). VERIFIED end-to-end: built MSI+NSIS 0.1.6; WiX main.wxs shows vulkan-1.dll as a Component in the same install dir as whispassist.exe + sibling DLLs (onnxruntime/sherpa/whispassist_lib), so the loader finds it. DirectML stays hidden when vulkan compiled (directml_would_help returns false). STILL TO TEST BY USER: launch the merged installer on a clean VM with NO vulkan-1.dll / no GPU driver to confirm graceful CPU fallback before retiring the 13MB build.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-07T00:06:25*
|
||||
|
||||
### WhispAssist 5-feature batch (2026-07-06, branch fe...
|
||||
|
||||
WhispAssist 5-feature batch (2026-07-06, branch feature_chore_bug_003): (1) Notes single-pane Editor/Preview toggle in TranscriptNotes.svelte (one button flips label Preview<->Editor). (2) Recording playback FR-REC-5: command recording_playback_path decrypts vault-sealed wav to audio.play.wav; enabled tauri protocol-asset feature + assetProtocol scope [$LOCALDATA/WhispAssist/meetings/**] + media-src CSP; SummaryPanel <audio controls> via convertFileSrc. (3) Smaller wav FR-CAP-8: wav_spec_for now forces 16-bit Int at native rate; write_wav_bytes quantizes f32->i16 via f32_to_i16 (clamp*i16::MAX) — halves 205MB->~103MB. Kept native rate (48k), did NOT resample to 44.1k (marginal, needs multichannel resampler in hot path). (4) Cancel recording FR-CAP-9: cancel_recording command stops loopback+mic, joins worker, store.delete_meeting (row+folder), emits recording://state state:cancelled; App.svelte Cancel button with confirm. (5) Live sync progress FR-SYNC-11: sync put() streams body via futures_util::stream::unfold + reqwest wrap_stream + Content-Length, sends incremental (sent,total); upload_job drains on a std thread (throttled 250ms) calling on_progress; pump_sync emits live sync://job; SummaryPanel <progress> bar. All clippy-clean, svelte-check clean, vite+full cargo build pass.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T22:57:29*
|
||||
|
||||
### WhispAssist 0.1.6 release build (2026-07-06): Vulk...
|
||||
|
||||
WhispAssist 0.1.6 release build (2026-07-06): Vulkan build (npm run tauri build -- --features vulkan; VULKAN_SDK=C:\VulkanSDK\1.4.350.0, CMAKE_GENERATOR=Ninja, CARGO_TARGET_DIR=C:\wt, use QUOTED 'set "VAR=val"' to avoid trailing-space bug). Headline fix vs 0.1.5: keyring windows-native (OS credential store was a no-op mock; sync/AI creds never persisted). Artifacts in C:\wt\release\bundle\: msi\WhispAssist_0.1.6_x64_en-US.msi (27MB, SHA256 ffbe32d9b53f2feaaa4b8a6a858b2f283cc5520b7e77814bc5cf7a41e04b5301), nsis\WhispAssist_0.1.6_x64-setup.exe (8.8MB, SHA256 669147d9578b2644b0838de346dda9ce7edd2b614a18f63ca5f61ffd64c6b526). SHA256SUMS.txt written to C:\wt\release\bundle\. Upload the .msi + -setup.exe + SHA256SUMS.txt; NOT the .7z runtime bundles (hosted in-repo via Git LFS, pulled from /media/branch/main/runtime/). 27MB MSI confirms Vulkan (CPU-only=11MB). Commits since 0.1.5: keyring fix 33c0397, version bump 6a4b832, plus sync-target-edit, Nextcloud server-URL auto-build, dark-theme fixes, About page, 7z runtime download.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T18:14:30*
|
||||
|
||||
### WhispAssist 0.1.5 shipped (2026-07-06, branch chor...
|
||||
|
||||
WhispAssist 0.1.5 shipped (2026-07-06, branch chore_debug): two UI fixes + version bump, 6 commits (035abb9..0d62416). (1) Settings panel horizontal-scroll/close-button-cutoff FIXED: the tab <nav> in Settings.svelte didn't wrap inside the fixed-width panel (width:min(720px,92vw)), overflowing and pushing the .close (X) button off-edge + adding a horizontal scrollbar. Fix: nav { flex:1 1 auto; min-width:0; flex-wrap:wrap }, header align-items:flex-start, .close flex:0 0 auto. (2) New ABOUT page (Settings ▸ About, Info icon tab): shows version (env!(CARGO_PKG_VERSION)) + build commit hash + a source link to https://git.dou.bet/iamdoubz/WhispAssist. Commit hash baked at build time via build.rs (git rev-parse --short HEAD -> cargo:rustc-env=WA_GIT_HASH, rerun-if-changed=../.git/logs/HEAD). Two new Tauri commands: app_info()->{version,commit}, open_url(url) (validates http(s), Windows-only via 'explorer <url>' — no shell injection, reuses installed toolchain instead of adding tauri-plugin-opener). api.ts got AppInfo type + appInfo()/openUrl() bindings. NOTE the section {#if}/{:else if} chain in Settings.svelte: privacy was the catch-all {:else} — adding an About branch required converting privacy to {:else if section==="privacy"} because {:else if} can't follow {:else}. Version bumped 0.1.4->0.1.5 in package.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json, Cargo.lock (package-lock.json tracks app version as 0.0.0, untouched). clippy -D warnings + svelte-check + eslint(my files) all clean.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T12:48:09*
|
||||
|
||||
### WhispAssist P2 DirectML GREEN end-to-end (2026-07-...
|
||||
|
||||
WhispAssist P2 DirectML GREEN end-to-end (2026-07-05): staged Microsoft.ML.OnnxRuntime.DirectML v1.24.1 (EXACT match to the OpenVINO bundle's ORT 1.24.1, from nuget flat-container api.nuget.org/v3-flatcontainer/microsoft.ml.onnxruntime.directml/1.24.1/...nupkg) into %LOCALAPPDATA%/WhispAssist/runtime/directml/ = onnxruntime.dll (17MB, DML EP baked in) + onnxruntime_providers_shared.dll. DirectML.dll NOT in the nuget — the Win11 System32 DirectML.dll (v1.15.5) satisfied it. Ran directml_transcribes_speech spike (no ORT_DYLIB_PATH; ensure_runtime_env pointed ort at runtime/directml/onnxruntime.dll + prepended its dir to PATH): backend=Intel (Arc iGPU via DirectMLExecutionProvider device_id 0), load=1222ms, infer=453ms, transcript='(gentle music)' (test wav was music; non-empty => PASS). So the full P0+P2 DirectML path is proven working on real GPU hardware, infer time on par with Vulkan/NPU. Repro: nuget version MUST be >= the ORT the OpenVINO bundle ships (1.24.x) for ABI/symbol match with ort rc.10. Older DirectML nugets (1.20-1.23) would risk GetProcAddress misses.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T02:30:59*
|
||||
|
||||
### WhispAssist sync-target EDIT feature added (2026-0...
|
||||
|
||||
WhispAssist sync-target EDIT feature added (2026-07-06, commits 149f2c9/45b2acb/496a337/fb79009). Root cause of the user's Nextcloud auth failure: the saved target's base_url was missing the /remote.php/dav/files/<user>/ path (they entered just the host) -> PROPFIND hit a non-DAV path -> 401. Credentials + app password were CORRECT (verified via curl PROPFIND returning 207 + X-User-Id). The app had NO way to edit a saved target — only add/delete/toggle-enabled — so they couldn't fix the URL. FIX: the backend update_sync_target command + store + api.updateSyncTarget ALREADY existed (was only used by the enable toggle); added the missing UI. Changes: (1) SyncTargetInfo (models.rs + api.ts) gained upload_transcript/notes/summary/recording, trigger_on_finalize, allow_plaintext_lan, encrypt_before_upload + row_to_info populates them (secret still never exposed, FR-SYNC-6); (2) settings.svelte.ts store.updateTarget(); (3) Settings.svelte: per-webdav-target Edit button -> startEdit() loads it into the add-form (secret blank), heading/submit become 'Edit target'/'Save changes', kind tabs hidden during edit, Cancel button. Password left blank on save = keep stored (update only rotates the credential when a non-empty secret is provided). Test-connection in edit mode tests the TYPED values (form has no id) so it needs the password re-entered; the simpler fix-and-save path preserves the secret. Correct Nextcloud WebDAV base_url = https://HOST/remote.php/dav/files/USERNAME/ . clippy + svelte-check + eslint all clean.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T15:41:28*
|
||||
|
||||
### WhispAssist 0.1.5 release build (2026-07-06, first...
|
||||
|
||||
WhispAssist 0.1.5 release build (2026-07-06, first public release): Vulkan build via 'npm run tauri build -- --features vulkan' with env VULKAN_SDK=C:\VulkanSDK\1.4.350.0, CMAKE_GENERATOR=Ninja (ninja 1.10.2 at C:\Tools\Standalone), CARGO_TARGET_DIR=C:\wt, vcvars64 loaded. GOTCHA that failed the first attempt: cmd 'set CARGO_TARGET_DIR=C:\wt && ...' captured a TRAILING SPACE (C:\wt ) -> 'failed to create directory C:\wt \release'; fix = quoted set: set "CARGO_TARGET_DIR=C:\wt". Artifacts in C:\wt\release\bundle\: msi\WhispAssist_0.1.5_x64_en-US.msi (27MB, SHA256 ed83ad0001c654221f3e5787088d922a1211d2722acbd5c2b4523f4f98c745e6), nsis\WhispAssist_0.1.5_x64-setup.exe (8.8MB, SHA256 dfa3a3acf7e9fdfe6d104527d55f660f0b9c0c1364da0cf665113aad60c61422). SHA256SUMS.txt written to C:\wt\release\bundle\. 27MB MSI size confirms Vulkan (CPU-only was 11MB). Release page should upload: the .msi, the -setup.exe, and SHA256SUMS.txt — NOT the .7z runtime bundles (those are hosted in-repo via Git LFS, pulled on-demand from /media/branch/main/runtime/).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T15:03:01*
|
||||
|
||||
### WhispAssist microphone-capture feature FR-CAP-7 (2...
|
||||
|
||||
WhispAssist microphone-capture feature FR-CAP-7 (2026-07-06, branch feature_chore_bug_002): WA now optionally captures the user's mic alongside loopback and mixes both 16kHz-mono streams into the single transcription worker via audio::spawn_mixer (Mixer struct sums+clamps aligned samples, forwards survivor when one source stalls/ends). New: WasapiCapture::start_microphone (Direction::Capture, no WAV), audio::list_capture_devices, list_input_devices command, Settings.microphone_enabled(default true)+audio_input_device. capture_loop generalized: wav_path Option, direction param, emit_level only for loopback. RecordingSession.mic_capture Option; stop/pause/resume handle both. Settings>Hardware>Audio Devices got a Microphone picker (Off/Default/devices). Loopback WAV stays byte-accurate native; mic is transcript-only (not in WAV/diarization) - tracked ponytail limitation.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T21:34:29*
|
||||
|
||||
### WhispAssist P0+P2 GPU acceleration SHIPPED (2026-0...
|
||||
|
||||
WhispAssist P0+P2 GPU acceleration SHIPPED (2026-07-05, branch chore_debug). P0 (2 commits e9567b8,7fe0d87): AccelPath enum {WhisperCpu,WhisperVulkan,WhisperCuda,OnnxOpenVino,OnnxDirectML} + resolve_accel(_with) in hardware/mod.rs = single source of truth; BackendInfo.available now derived from it (DXGI + NPU), closing the no-op-GPU detection gap; load_transcriber drives engine choice from it. 3 new pure resolver unit tests, all green. P2 (DirectML for AMD/Intel non-Vulkan): ort gains 'directml' feature; OnnxNpuTranscriber RENAMED to OnnxTranscriber (file still npu.rs) — load() now picks OpenVINO(NPU) vs DirectML(Amd/Intel, device_id 0) EP by BackendId over the SAME onnx artifacts; ensure_runtime_env takes the runtime dll path; paths::directml_runtime_dir/dll/ready added (C:\Users\dadous\AppData\Local/WhispAssist/runtime/directml/onnxruntime.dll); download_and_extract_runtime parameterized (sha+ready) and reused by new stage_directml_runtime + download_directml_package command (registered in lib.rs); hardware_status reports a directml field. 104 lib tests + clippy -D warnings all green across feature sets. TESTED end-to-end: directml_transcribes_speech spike (ignored test in npu.rs, env WA_DML_MODEL_DIR/WA_DML_TEST_WAV/WA_DML_BACKEND) run with ORT_DYLIB_PATH=the existing OpenVINO onnxruntime.dll -> FAILED as expected with 'GetProcAddress OrtSessionOptionsAppendExecutionProvider_DML failed' = the OpenVINO ORT build has NO DirectML EP. This PROVES the DML dispatch path selects the EP and fails LOUDLY (error_on_failure) instead of silently. REMAINING for a GREEN GPU run: stage a real DirectML-EP onnxruntime.dll (Microsoft.ML.OnnxRuntime.DirectML) into runtime/directml/, VERSION-MATCHED to ort rc.10 / ORT ~1.24.x (OpenVINO bundle is ORT 1.24.1). No hosted DirectML bundle published yet; WA_DIRECTML_RUNTIME_URL env drives download, empty SHA. CUDA (P1) NOT done yet (needs NVIDIA hw/CI).
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-06T00:45:47*
|
||||
|
||||
### WhispAssist DirectML Settings toggle WIRED (2026-0...
|
||||
|
||||
WhispAssist DirectML Settings toggle WIRED (2026-07-06, branch chore_debug, 4 commits 281ccf0/ceb7e59/1b743da/1b74e0b). Backend: hardware::directml_would_help() (pub, in hardware/mod.rs) = cfg!(feature=npu) && !cfg!(feature=vulkan) && a present AMD/Intel GPU (or NVIDIA when !cfg!(cuda)) via dxgi::enumerate_gpus() presence — gates the card so it stays HIDDEN on the shipping Vulkan build (Vulkan already covers all GPUs) and only shows on CUDA/non-Vulkan builds where a GPU lacks coverage. hardware_status now returns directml:{applicable,runtimeReady,modelInstalled} (mirrors the npu:{} field). Frontend: api.ts HardwareStatus gained directml?:{applicable,runtimeReady,modelInstalled} + api.downloadDirectmlPackage(); Settings.svelte has a 'GPU acceleration (DirectML)' card mirroring the NPU package card (reuses .npu-package CSS), shown when directml.applicable, Ready badge when runtimeReady&&modelInstalled else a Download button -> downloadDirectmlPackage() -> loadHardware(). Progress is await-driven (busy flag), NO % listener — DirectML runtime/model progress still emits on the cosmetic npu://download channel (only 'done' on directml://download). It's a package-DOWNLOAD action, not a persistent on/off toggle; the real 'use this GPU' switch is the existing Preferred-backend dropdown (which now enables AMD/Intel once staged, via the P0 availability fix). clippy -D warnings + svelte-check both clean (fixed a needless_return in directml_would_help by using the cfg-block tail-expression pattern like npu_hardware_present).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T12:27:50*
|
||||
|
||||
### WhispAssist release 0.1.4 = first Vulkan-enabled b...
|
||||
|
||||
WhispAssist release 0.1.4 = first Vulkan-enabled build. Artifacts: C:\wt\release\bundle\msi\WhispAssist_0.1.4_x64_en-US.msi (27MB), C:\wt\release\bundle\nsis\WhispAssist_0.1.4_x64-setup.exe (8.7MB), C:\wt\release\whispassist.exe (64MB) — sizes jumped from 11MB/4.1MB/13MB (0.1.3 CPU-only) because the Vulkan backend + embedded SPIR-V shaders are statically compiled in. Built via 'npm run tauri build -- --features vulkan' with the [[whispassist-vulkan-build-recipe]] env. REMAINING WIRING GAPS (not yet done): (1) detection-honesty gating — mark GPU backends 'available' only when a vulkan/cuda feature is compiled (cfg!(feature=...)), else best() routes to a no-op GPU; (2) make Vulkan the standing release build flag instead of manual --features vulkan; (3) preferred_backend UI so the user can force Intel GPU (currently best() picks NPU rank-0 over Intel rank-3 on this machine, so the app uses NPU not Vulkan unless overridden).
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:13*
|
||||
|
||||
### WhispAssist Nextcloud sync UX overhaul (2026-07-06...
|
||||
|
||||
WhispAssist Nextcloud sync UX overhaul (2026-07-06, commits 8f6e804/9c670f9). ROOT CAUSE of user's confusion (dwdoubet@box.dou.bet): they were editing with the password field BLANK. WebDavTarget.test() does PROPFIND on base_url+remote_base_path. Bare host https://box.dou.bet + blank pw -> PROPFIND https://box.dou.bet/WhispAssist = NON-DAV path -> 404 (no auth needed) -> test treats 404 as success -> FALSE 'Connected'. Full DAV URL + blank pw -> real DAV endpoint -> 401 -> 'auth failed'. So bare-host 'Connected' was a false positive. FIX 1: WebDavTarget gained provider_hint field; for nextcloud/owncloud, dav_root() derives origin (scheme+host+port) from base_url and builds {origin}/remote.php/dav/files/{username}/ — so users enter ONLY the server URL (https://box.dou.bet) and the app builds the canonical DAV path; a pasted full path is normalized via origin(). Other providers (seafile /seafdav, synology /dav, cloudreve, generic) still use base_url verbatim. url_for uses dav_root(). FIX 2: test_sync_target — for an EXISTING webdav target (id present), it now builds WebDavTarget from the stored row's credential_ref (STORED password) + applies form overrides (base_url/username/remote_base_path/provider_hint/allow_plaintext_lan), unless the user typed a NEW password (temp cred). So Test works after a URL fix WITHOUT re-entering the password. Frontend: davAutoPath derived (provider is nextcloud/owncloud) drives the Server URL placeholder/hint; testConnection passes id in edit mode. Test nextcloud_builds_dav_path_from_server_url added. Correct Nextcloud username here = dwdoubet, host box.dou.bet. NOTE: their existing saved target may just start working after this (url_for rebuilds path from origin+username) if provider_hint=nextcloud. clippy --all-targets + svelte-check + eslint clean; 13 sync tests pass.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T16:11:14*
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
*Failure records, bugs, and lessons learned from mistakes.*
|
||||
|
||||
### WhispAssist crash to INVESTIGATE LATER (2026-07-06...
|
||||
|
||||
WhispAssist crash to INVESTIGATE LATER (2026-07-06, dev build, branch feature_chore_bug_002): app crashed with exit code 0x80000003 (STATUS_BREAKPOINT) around DirectML use on an Intel GPU. Repro sequence: NPU recording worked fine (mic capture confirmed working); user then switched to test DirectML, downloaded the DirectML components, then hit Record and it crashed — crash may have occurred DURING the component download or immediately after starting recording. Log evidence at crash: WARN 'ONNX engine load failed (model load failed: Error attempting to load symbol OrtSessionOptionsAppendExecutionProvider_DML from dynamic library: GetProcAddress failed); falling back to CPU', then whisper_model_load loading ggml-small.en-q5_1.bin, then process exited 0x80000003. CONFIRMED unrelated to the microphone/mixer feature (FR-CAP-7) — it's in the DirectML/ONNX + whisper model-load path. Suspect: DirectML runtime download/activation or DML EP symbol-load failure interacting with recording start. Next: reproduce by enabling DirectML on Intel GPU + start recording; check GetProcAddress DML symbol load and whether crash is during download vs whisper load.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-06T21:59:37*
|
||||
|
||||
### WhispAssist build gotcha (cost ~1hr this session, ...
|
||||
|
||||
WhispAssist build gotcha (cost ~1hr this session, masqueraded as a Node 26 incompatibility): NEVER use PowerShell 'Set-Content -Encoding utf8' on package.json / tauri.conf.json / any JSON or TOML — Windows PowerShell 5.1 writes UTF-8 WITH a BOM. The BOM in package.json breaks vite (fails 'type:module' detection -> 'This package is ESM only but was loaded by require' for @sveltejs/vite-plugin-svelte) AND vitefu (JSON.parse chokes: 'Unexpected token, not valid JSON' -> 'Unable to read package.json'), which fails 'npm run build' / the whole tauri build. Fix: use the Edit tool, or sed, or [System.IO.File]::WriteAllText. Strip an existing BOM with: sed -i '1s/^\xef\xbb\xbf//' file. Node was v26.3.0 at C:\Tools\node but Node was NOT the cause.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-05T22:04:14*
|
||||
|
||||
### Critical recurring issue: WhispAssist debug (dev) ...
|
||||
|
||||
Critical recurring issue: WhispAssist debug (dev) builds hang/stall during whisper.cpp model loading on this machine, apparently due to Trend Micro AV behavior-monitoring interfering with ZwWriteVirtualMemory calls made during model load. Confirmed reproducible even in a bare standalone Rust example binary with zero Tauri/webview involvement, so it is specific to whisper.cpp model loading in a debug-profile binary, not the app shell. Release (optimized+stripped) builds do NOT hit this - confirmed by the user and by direct testing. Escalated to IT, unresolved as of 2026-07-02. Workaround: use release builds for real testing/spot-checking; expect dev-mode launches to sometimes hang at model load and need force-killing.
|
||||
|
||||
*Confidence: 0.9 | Status: active | Created: 2026-07-02T20:14:14*
|
||||
|
||||
### WhispAssist CRITICAL BUG FOUND + FIXED (2026-07-06...
|
||||
|
||||
WhispAssist CRITICAL BUG FOUND + FIXED (2026-07-06): the OS credential store was a NO-OP the entire time. keyring 3.x feature-gates its platform backends and they are OFF by default; 'keyring = { version = "3", optional = true }' had NO backend feature, so on Windows keyring silently used its MOCK keystore. The mock does NOT persist across keyring::Entry instances, so credentials::set (Entry A) appeared to succeed while credentials::get (a fresh Entry B, same service+name) always returned NoEntry -> has_secret=false -> no HTTP Basic auth sent -> 401. This broke ALL sync WebDAV auth, and would break MCP/hosted-AI keys + OAuth tokens too (anything via sync::credentials, SERVICE='WhispAssist-sync'). FIX: keyring = { version = "3", optional = true, features = ["windows-native"] } (pulls dep:windows-sys = real Windows Credential Manager). Confirmed: WA_SYNC_TEST_DIAG went from status=401 has_secret=false to status=207 has_secret=true. DIAGNOSIS JOURNEY (Nextcloud box.dou.bet user dwdoubet): symptom 'auth failed'; the user's credentials + full DAV URL were valid (curl PROPFIND 207). Red herrings: (a) earlier the Nextcloud base_url was missing /remote.php/dav/files/<user>/; (b) a bare-host test gave a FALSE 'Connected' because a non-DAV path 404 is treated as success; (c) blank password in edit test. Real root cause was keyring. TOOLING NOTE: diagnosed via temporary tracing::info! logs (WA_SYNC_CMD_DIAG in test_sync_target = has_id+secret_len; WA_SYNC_SET_DIAG in add branch; WA_SYNC_TEST_DIAG in WebDavTarget::test() = url+status+has_secret) read live from the 'npm run tauri dev' output (tracing filter is 'info' in lib.rs). Also learned keyring feature name = windows-native, and the app's tracing default level is info. These temp diagnostics MUST be removed before release.
|
||||
|
||||
*Confidence: 1.0 | Status: active | Created: 2026-07-06T18:06:13*
|
||||
|
||||
### WhispAssist debug-CRT assertion (found 2026-07 whi...
|
||||
|
||||
WhispAssist debug-CRT assertion (found 2026-07 while running the non-vulkan CPU baseline in DEBUG): the whispassist_lib debug test binary throws MSVC Debug Assertion 'Expression: _osfile(fh) & FOPEN' at ucrt read.cpp:381 = a read() on a CLOSED/invalid file handle. It pops a MODAL Abort/Retry/Ignore dialog that HANGS the test (this is what stalled the overnight non-vulkan baseline run for 8 hours). Only fires under the debug CRT (-MDd); the RELEASE 0.1.4 build does NOT assert (release CRT skips the check), so shipping is unaffected — but the underlying 'read on a closed handle' is latent UB worth root-causing. Likely in the transcription file-read path (whisper.cpp model load or our audio read_wav_mono_16k / vault::open passthrough). ADD to the CPU testing round: investigate this handle bug alongside the ~90s transcribe_file slowness (may or may not be related). Workaround to get a clean non-vulkan CPU number: run 'cargo test --release' (no debug CRT dialog), not plain 'cargo test'.
|
||||
|
||||
*Confidence: 0.95 | Status: active | Created: 2026-07-05T22:07:51*
|
||||
|
||||
---
|
||||
|
||||
*End of memory export.*
|
||||
@@ -7,11 +7,14 @@ on-device acceleration (**NPU → GPU → CPU**), labels speakers, structures th
|
||||
Markdown notes, and optionally augments them with a locally hosted LLM (Ollama). Audio and
|
||||
transcripts **never leave the machine** unless you explicitly configure a destination.
|
||||
|
||||
> **Status: working application (v0.1.5).** Capture, transcription (CPU / Intel NPU / Vulkan
|
||||
> GPU), speaker diarization, storage + crash recovery, local-LLM summaries, opt-in recording,
|
||||
> at-rest encryption, and self-hosted sync are implemented and ship as signed **MSI + NSIS**
|
||||
> installers. Outlook `.pst`/calendar context and the coding-agent (MCP) handoff are in
|
||||
> progress. Build order and remaining tasks are in [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
> **Status: working application (v0.4.0).** Capture (system audio **+ your microphone**, with a
|
||||
> live **dual level meter**), transcription (CPU / Intel NPU / Vulkan GPU) with a **fluid live
|
||||
> transcript**, speaker diarization, storage + crash recovery, local-LLM summaries, AI tags,
|
||||
> opt-in recording with **in-app playback**, at-rest encryption, self-hosted sync, **Outlook
|
||||
> `.pst`/calendar import with optional auto-record**, **importing an existing recording from a
|
||||
> file or URL**, and a loopback **MCP server** for coding-agent handoff are all implemented and
|
||||
> ship as a single signed **MSI + NSIS** universal installer. Build order and remaining tasks
|
||||
> are in [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
|
||||
## Why WhispAssist — Granola vs Meetily vs WhispAssist
|
||||
|
||||
@@ -25,17 +28,21 @@ transcripts **never leave the machine** unless you explicitly configure a destin
|
||||
| **Speaker diarization** | ✅ cloud | ⚠️ limited | ✅ offline (sherpa-onnx) |
|
||||
| **Summaries / notes AI** | ☁️ cloud LLM | ✅ local (Ollama) / BYO | ✅ local (Ollama, localhost **or LAN**) + optional hosted |
|
||||
| **Default data egress** | ☁️ audio + notes to cloud | 🔒 local (cloud optional) | 🔒 **none** — everything off by default, allowlist-enforced |
|
||||
| **Calendar / Outlook `.pst` context** | ✅ cloud calendar | ❌ | ⚙️ local `.pst` — in progress |
|
||||
| **Calendar / Outlook `.pst` context** | ✅ cloud calendar | ❌ | ✅ local `.pst` import + **auto-record on events** |
|
||||
| **Import an existing recording (file/URL)** | ❌ live capture only | ❌ | ✅ any file or URL (ffmpeg + yt-dlp) |
|
||||
| **Opt-in recording + consent notice** | ⚠️ partial | ❌ | ✅ off by default, one-time consent |
|
||||
| **At-rest encryption** | ☁️ server-side | ❌ | ✅ vault: Argon2id + XChaCha20-Poly1305 |
|
||||
| **Self-hosted sync w/ client-side encryption** | ❌ | ⚠️ | ✅ WebDAV + OAuth, **encrypt-before-upload** |
|
||||
| **Coding-agent (MCP) handoff** | ❌ | ❌ | ⚙️ local MCP server — in progress |
|
||||
| **Coding-agent (MCP) server** | ✅ **cloud** MCP (notes via their servers) | ❌ | ✅ **local, loopback-only**, token-gated, adds no egress |
|
||||
| **License** | Proprietary | Open source (MIT) | Open source (MIT / Apache-2.0) |
|
||||
| **Cost** | Subscription | Free | Free |
|
||||
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola and Meetily
|
||||
are independent products and their capabilities evolve — verify current details before relying
|
||||
on any row.</sub>
|
||||
<sub>Comparison reflects each project's public positioning as of mid-2026. Granola now ships an
|
||||
MCP server too, but it is **cloud-hosted** — an agent reaching it pulls your notes through
|
||||
Granola's servers; WhispAssist's MCP server is **loopback-only and adds no egress of its own**
|
||||
(data leaves only via the connected agent's own provider, which WA discloses). Granola and
|
||||
Meetily are independent products and their capabilities evolve — verify current details before
|
||||
relying on any row.</sub>
|
||||
|
||||
**The short version:** Granola is the polished cloud option (your audio and notes are processed
|
||||
on their servers). Meetily is the closest peer — open-source and self-hosted — but is
|
||||
@@ -44,37 +51,64 @@ accelerated, zero-egress-by-default** option: it exploits the NPU/GPU in modern
|
||||
everything on the device unless you opt in, and adds Windows-specific context (Outlook) and a
|
||||
coding-agent handoff.
|
||||
|
||||
## What's built (v0.1.5)
|
||||
## What's built (v0.4.0)
|
||||
|
||||
- **Bot-free capture** — WASAPI loopback records the system mix (all participants) with no
|
||||
meeting bot and no per-app plumbing.
|
||||
- **Local transcription with a hardware ladder** — whisper.cpp via `whisper-rs` on CPU; the
|
||||
**Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single binary that runs on
|
||||
NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**), streams partial transcripts live, and shows the active
|
||||
backend in the UI.
|
||||
- **Speaker diarization** — `sherpa-onnx` (pyannote segmentation + speaker-embedding
|
||||
clustering), fully offline.
|
||||
- **Bot-free capture — now both sides, with a live dual meter** — WASAPI loopback records the
|
||||
system mix (all participants), and an optional **microphone** path captures your own voice,
|
||||
mixed into both the live transcript and the saved recording. While recording, a **level meter
|
||||
overlays the system and microphone signals in two colours** so you can see both sides are being
|
||||
picked up. Pick a specific output/input or turn the mic off in **Settings ▸ Hardware**. No
|
||||
meeting bot, no per-app plumbing.
|
||||
- **Local transcription with a hardware ladder — and a fluid live transcript** — whisper.cpp via
|
||||
`whisper-rs` on CPU; the **Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single
|
||||
binary that runs on NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
|
||||
(**NPU → NVIDIA → AMD → Intel → CPU**), and shows it in the UI. The **live transcript streams a
|
||||
growing line that refreshes ~once a second and commits at natural pauses** — words appear as
|
||||
they're spoken instead of in fixed multi-second blocks, so sentences aren't chopped across
|
||||
lines.
|
||||
- **Speaker diarization** — `sherpa-onnx` (pyannote segmentation + speaker-embedding clustering),
|
||||
fully offline. Install the two diarization models in **Settings ▸ Hardware** and finished
|
||||
recordings are split by speaker (Speaker 1, Speaker 2, …); the microphone speaker is
|
||||
auto-labelled from a short voiceprint.
|
||||
- **Import an existing recording** — add a meeting from a **local audio/video file or a URL**
|
||||
(YouTube, a streaming page, or a direct media link). WhispAssist transcribes and diarizes it
|
||||
just like a live recording. Uses **`ffmpeg`** (and **`yt-dlp`** for URLs), which you install
|
||||
yourself — neither is bundled.
|
||||
- **Notes & summaries** — Markdown notes; local-LLM summaries via **Ollama** on `localhost`
|
||||
**or a private LAN endpoint** (RFC-1918), with a full advanced-parameter panel (system prompt,
|
||||
`think`, `keep_alive`, `num_ctx`, sampling/repetition/mirostat, etc.).
|
||||
- **Storage & crash recovery** — SQLite + on-disk audio/transcripts under
|
||||
`%LOCALAPPDATA%\WhispAssist`. Audio is the source of truth; notes and transcripts regenerate
|
||||
after a crash.
|
||||
- **Opt-in recording** — off by default; `.wav` retained only when you turn it on, after a
|
||||
one-time consent notice.
|
||||
- **Opt-in recording + in-app playback** — off by default; `.wav` retained only when you turn it
|
||||
on, after a one-time consent notice. Play a saved recording back in the app — encrypted
|
||||
recordings are decrypted **in memory on the fly** (nothing plaintext is written to disk).
|
||||
Recordings are 16-bit for roughly half the size, and an accidental recording can be **cancelled**
|
||||
(audio + transcript deleted).
|
||||
- **Notes, summaries & AI tags** — Markdown notes with an **Editor/Preview** toggle; local-LLM
|
||||
summaries and one-click **tag generation** with a chip-based tag editor and tag filtering.
|
||||
- **At-rest encryption vault** — Argon2id key derivation + XChaCha20-Poly1305; transcripts,
|
||||
notes, summaries, and recordings sealed on disk; startup unlock gate; keys zeroized on lock.
|
||||
- **Self-hosted sync (optional, off by default)** — WebDAV (Nextcloud, ownCloud, Cloudreve,
|
||||
Seafile, Synology) plus OneDrive/Dropbox/Box (OAuth 2.0 PKCE); durable retry queue with
|
||||
backoff; **client-side encryption before upload** so the destination holds only ciphertext.
|
||||
Credentials live only in the OS credential store.
|
||||
backoff and **live per-item upload progress**; **client-side encryption before upload** so the
|
||||
destination holds only ciphertext. Credentials live only in the OS credential store.
|
||||
- **Optional hosted AI** — Anthropic and OpenAI-compatible providers behind the same
|
||||
`LlmProvider` interface, off by default (third-party egress, keys in the OS credential store).
|
||||
- **Installers** — signed MSI and NSIS `-setup.exe`.
|
||||
- **Outlook `.pst` / calendar context, with auto-record** — import events and attendees from a
|
||||
local Outlook `.pst` backup (read-only, range-limited, de-duplicated, with cleanup), attach
|
||||
meetings to events, and — opt-in — **auto-start recording when a calendar event begins** while
|
||||
the app is open (a one-shot timer, no background polling).
|
||||
- **Local MCP server for coding-agent handoff** — hand meeting context to your own coding agents
|
||||
(Claude, Codex, Copilot, OpenCode) over a **loopback-only, token-gated** MCP server that is off
|
||||
by default, scope-limited, audited, and **adds no egress** — data leaves only via the agent's
|
||||
own provider, which WA discloses.
|
||||
- **One universal installer** — a single signed MSI and NSIS `-setup.exe` that covers every
|
||||
machine: Vulkan for all GPUs, the Intel NPU path (+ DirectML fallback), and CPU. The Vulkan
|
||||
loader is bundled so it launches even on machines without a GPU driver.
|
||||
|
||||
**In progress:** Outlook `.pst` + calendar context, the local **MCP server** that hands meeting
|
||||
context to your coding agents (Claude, Codex, Copilot, OpenCode), and MS Graph calendar.
|
||||
**In progress:** Microsoft Graph calendar (cloud calendar via OAuth) and an optional CUDA
|
||||
(NVIDIA-only) build variant.
|
||||
|
||||
## Quick start (install)
|
||||
|
||||
@@ -96,6 +130,14 @@ data under `%LOCALAPPDATA%\WhispAssist`.
|
||||
|
||||
Prefer the NSIS installer? Grab **`WhispAssist_<version>_x64-setup.exe`** from the same page.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
If you do not have these installed, WhispAssist will still work, but some features will be unavailable.
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases)
|
||||
- [ffmpeg](https://www.ffmpeg.org/download.html#build-windows)
|
||||
- [libpst](https://sourceforge.net/projects/ezwinports/files/libpst-0.6.63-w32-bin.zip/)
|
||||
|
||||
## Technology
|
||||
|
||||
WhispAssist is a **Tauri 2** application: a small Rust core with a compiled **Svelte + TypeScript**
|
||||
@@ -110,7 +152,7 @@ and its alternatives are recorded in [`docs/adr/`](docs/adr/) (ADR-0001–0011).
|
||||
- **Storage:** SQLite + on-disk audio/transcript files
|
||||
- **Local LLM:** Ollama HTTP API (localhost or a private LAN endpoint)
|
||||
- **Encryption:** Argon2id + XChaCha20-Poly1305 envelope vault; secrets in the OS credential store
|
||||
- **Calendar / Outlook:** `outlook-pst` for `.pst`, OS notifications for reminders *(in progress)*
|
||||
- **Calendar / Outlook:** `readpst` for `.pst` import, OS scheduled toasts for action-item reminders
|
||||
- **Sync:** WebDAV primary set + OneDrive/Dropbox/Box via OAuth — off by default (ADR-0010)
|
||||
- **External AI / agents:** hosted providers behind `LlmProvider`; a loopback-only **MCP server**
|
||||
for coding-agent handoff — off by default (ADR-0011)
|
||||
@@ -142,18 +184,28 @@ npm install
|
||||
npm run tauri dev # CPU/NPU build
|
||||
```
|
||||
|
||||
**GPU (Vulkan) build.** whisper.cpp's GPU backends are compiled in (not downloaded at runtime),
|
||||
so a GPU build needs a one-time toolchain setup — the **Vulkan SDK**, a **Ninja** generator, and
|
||||
a short target dir (to dodge Windows' 260-char path limit in the shader build):
|
||||
**Release build (single universal installer).** whisper.cpp's GPU backends are compiled in (not
|
||||
downloaded at runtime), so the release build needs a one-time toolchain setup — the **Vulkan SDK**,
|
||||
a **Ninja** generator, and a short target dir (to dodge Windows' 260-char path limit in the shader
|
||||
build):
|
||||
|
||||
```bash
|
||||
# after: Vulkan SDK installed, ninja.exe on PATH, vcvars64 loaded
|
||||
set VULKAN_SDK=C:\VulkanSDK\1.4.350.0
|
||||
set CMAKE_GENERATOR=Ninja
|
||||
set CARGO_TARGET_DIR=C:\wt
|
||||
npm run tauri build -- --features vulkan
|
||||
npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json
|
||||
```
|
||||
|
||||
This one build covers **every** machine: Vulkan accelerates all GPUs (NVIDIA/AMD/Intel), the Intel
|
||||
NPU path works via the runtime OpenVINO download, and CPU is the fallback. The `--features vulkan`
|
||||
binary links `vulkan-1.dll`, so `build.rs` stages the redistributable Vulkan **loader** (from
|
||||
`VULKAN_SDK\Bin`, or System32) next to the exe and `tauri.vulkan.conf.json` bundles it into the
|
||||
installer — the app then launches even on a machine with no GPU driver (it reports zero Vulkan
|
||||
devices and decodes on the CPU). DirectML is intentionally not offered here because Vulkan already
|
||||
covers those GPUs; it's the GPU path only in the plain `npm run tauri build` (no Vulkan) variant,
|
||||
kept as an internal fallback.
|
||||
|
||||
CUDA (NVIDIA-only, faster) is planned as an optional variant. The full, gotcha-annotated build
|
||||
recipe lives in the project notes.
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
|
||||
| FR-CAP-4 | M | 1 | Show an unambiguous "recording active" indicator (in-app banner + tray icon). |
|
||||
| FR-CAP-5 | S | 7 | Render a live input waveform / level meter while recording. |
|
||||
| FR-CAP-6 | S | 7 | Handle audio device changes mid-recording without losing the session. |
|
||||
| FR-CAP-7 | S | 1 | Optionally capture the user's **microphone** alongside loopback, mixing it into **both** the live transcript and the saved recording (default ON, local-only/no egress, selectable device + "off"). |
|
||||
| FR-CAP-8 | S | 1 | Write the retained recording as **16-bit PCM** at the device's native rate/channels — roughly half the size of the 32-bit-float mix, with no material quality loss for speech. |
|
||||
| FR-CAP-9 | S | 1 | **Cancel** an in-progress recording: stop capture, delete working files, and remove the meeting from the DB entirely (for one started by mistake — no finalize/transcript/sync). |
|
||||
|
||||
### Recording retention & consent (REC) — see ADR-0009
|
||||
|
||||
@@ -25,6 +28,7 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
|
||||
| FR-REC-2 | M | 1 | Before retaining a recording for the first time (and shown near the toggle thereafter), display a consent notice: recording without participants' consent may be illegal in some regions; advise checking local laws. Require a one-time acknowledgment; store it. This is a caution, not legal advice. |
|
||||
| FR-REC-3 | M | 1 | When retention is on, the UI indicates the meeting is being **saved** (in addition to the "recording active" indicator, FR-CAP-4). |
|
||||
| FR-REC-4 | M | 2 | Deleting working audio on finalize happens only **after** the transcript is successfully finalized; never race with crash recovery (audio stays source of truth until then). |
|
||||
| FR-REC-5 | S | 2 | Play a meeting's retained `.wav` back in the app (decrypting a vault-sealed recording on demand for playback). |
|
||||
|
||||
### Hardware acceleration (HW)
|
||||
|
||||
@@ -152,6 +156,7 @@ the local LLM endpoint, and it is **off by default**. Primary targets are self-h
|
||||
| FR-SYNC-8 | M | 9 | Surface sync state in the UI and label targets: self-hosted/primary as "your server"; third-party clouds carry a clear "data leaves your device to a third party" banner. |
|
||||
| FR-SYNC-9 | S | 9 | **Secondary** targets via provider APIs + OAuth 2.0 (PKCE, loopback redirect): **OneDrive** (MS Graph), **Dropbox**, **Box**. |
|
||||
| FR-SYNC-10 | C | 9 | Optional client-side encryption of artifacts before upload (ties to FR-SEC-3): destination holds only ciphertext. |
|
||||
| FR-SYNC-11 | S | 9 | Show **live per-item upload progress** while syncing (stream the upload body and report bytes sent per artifact). |
|
||||
|
||||
### UX, accessibility, recovery (UX)
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ touches files, DB, or network directly — only Tauri commands/events (`04-api-c
|
||||
|
||||
| Service | Responsibility | Primary crate(s) |
|
||||
|---|---|---|
|
||||
| `audio` | WASAPI loopback capture; PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
|
||||
| `audio` | WASAPI loopback capture (+ optional microphone, mixed into the transcript stream, FR-CAP-7); PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
|
||||
| `hardware` | Enumerate NPU/GPU/CPU; rank backends; report capabilities | `ort`, DXGI via `windows` |
|
||||
| `transcription` | Load model on a backend; stream segments (whisper.cpp) or NPU (ONNX) | `whisper-rs`, `ort` |
|
||||
| `diarization` | Post-process audio → speaker spans; align to segments; merge | `sherpa-onnx` (FFI) |
|
||||
|
||||
+69
-8
@@ -16,17 +16,58 @@ Default root: `%LOCALAPPDATA%\WhispAssist\` (user-configurable, FR-STORE-2).
|
||||
└── <meeting_id>\ # one folder per meeting (uuid)
|
||||
├── audio.wav # canonical recording — present ONLY if "Record" was on (ADR-0009)
|
||||
├── transcript.json # canonical transcript (segments+speakers+timings)
|
||||
├── notes.md # user-editable Markdown notes
|
||||
├── manual_notes.json # raw user-authored notes captured live during recording
|
||||
├── notes.md # the final notes document: manual notes + transcript, merged at finalize
|
||||
├── summary.json # LLM summary, decisions, action items (if generated)
|
||||
└── briefs/ # feature briefs distilled from this meeting (ADR-0011), if any
|
||||
└── <brief_id>.json # agent-ready spec served via the MCP `get_feature_brief` tool
|
||||
```
|
||||
|
||||
A **bundle export** (`export_meeting` / `bulk_export_meetings` with `format: "bundle"`, FR-STORE-4)
|
||||
copies a meeting's `audio.wav`, `transcript.json`, `notes.md`, and `summary.json` (all decrypted)
|
||||
into a destination folder plus a `meeting.json` manifest (the `MeetingBundle`: title, timestamps,
|
||||
duration, language/backend/model, tags, speakers, and confirmed action items). `import_meeting_bundle`
|
||||
reconstructs each such folder under a fresh meeting id — the portable format for moving recordings
|
||||
between computers.
|
||||
|
||||
Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On
|
||||
finalize, it is **kept** as `audio.wav` if "Record this meeting" was on, or **deleted** if not
|
||||
(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json`,
|
||||
`notes.md`, and `summary.json` are **derived** and regenerable (regenerable only while the audio
|
||||
still exists — i.e. for recorded meetings).
|
||||
(FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json` and
|
||||
`summary.json` are **derived** and regenerable (regenerable only while the audio still exists —
|
||||
i.e. for recorded meetings).
|
||||
|
||||
`notes.md` is **generated once, at finalize**, by merging `manual_notes.json` (freeform notes
|
||||
typed live during the recording, plus any per-moment annotations — see below) with the rendered,
|
||||
speaker-tagged transcript (`notes::MarkdownNotes::merge`). After that it is the user's own
|
||||
document, freely editable via `update_notes` exactly like before this changed — nothing
|
||||
re-renders or overwrites it afterward. In particular, renaming or merging a speaker after finalize
|
||||
updates the `speakers` table and the live UI display, but does **not** retroactively rewrite text
|
||||
already baked into `notes.md` (same as any other manual edit isn't retroactively touched either —
|
||||
this was a pre-existing clobber bug this redesign also fixes: renaming a speaker used to silently
|
||||
overwrite the whole file). A crash-recovery finalize (T2.8) and a post-finalize batch
|
||||
re-transcription (T3.8) both re-render `notes.md` from scratch and so both re-read
|
||||
`manual_notes.json` from disk to fold the same manual notes back in.
|
||||
|
||||
### `manual_notes.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": 1,
|
||||
"freeform_md": "string — the user's running notes, typed live in the Notes pane while recording",
|
||||
"segment_notes": [
|
||||
{ "anchor_ms": 12345, "text": "string", "created_at": 1735000000, "updated_at": 1735000010 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`segment_notes[].anchor_ms` is a timestamp into the recording (a clicked transcript segment's
|
||||
`start_ms`), not a segment id — a later batch re-transcription can renumber/regenerate segment
|
||||
ids, but never moves the moment in time a note was attached to. At merge time, each note is placed
|
||||
right after whichever transcript paragraph's time span contains its `anchor_ms`; a note whose
|
||||
anchor doesn't land inside any paragraph surfaces under an "Other notes" section instead of being
|
||||
silently dropped. Written to disk on every edit via `update_live_notes`/`set_segment_note`
|
||||
(`04-api-contracts.md`) — live-session only, same write-through-for-crash-safety spirit as
|
||||
`transcript.json` accumulating during recording.
|
||||
|
||||
## SQLite schema (`wa.db`)
|
||||
|
||||
@@ -267,13 +308,22 @@ label so re-diarization or renaming never requires rewriting every segment (FR-S
|
||||
## `briefs/<brief_id>.json` (feature brief — ADR-0011)
|
||||
|
||||
Agent-ready spec the MCP `get_feature_brief` tool returns. Designed to drop straight into a coding
|
||||
agent's context.
|
||||
agent's context. Written by `create_feature_brief` (M1); one file per brief under the meeting's
|
||||
`briefs/` folder, **sealed at rest with the vault** when unlocked (T8.8), exactly like
|
||||
`summary.json`. The `feature_briefs` table indexes it (id, meeting_id, title, target_repo, path,
|
||||
exposed) for `list_feature_briefs` and the MCP scope check — the file is the source of truth; the
|
||||
row is the index. The IPC `FeatureBrief` type (`04-api-contracts.md`) is the subset returned to the
|
||||
UI / MCP: everything below **except** the `schema`/provenance envelope (`generated_at`, `provider`,
|
||||
`model`, `source`).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "b7a1…",
|
||||
"meeting_id": "f1c2…",
|
||||
"generated_at": 1751299200, // envelope: which model distilled this, and when
|
||||
"provider": "ollama",
|
||||
"model": "llama3",
|
||||
"title": "Bulk CSV export for the reporting view",
|
||||
"problem": "Customer can't get their data out for offline analysis.",
|
||||
"desired_outcome": "One-click CSV export of the current filtered report.",
|
||||
@@ -282,15 +332,21 @@ agent's context.
|
||||
"Respects active filters and column order",
|
||||
"Streams large exports without blocking the UI",
|
||||
],
|
||||
"target_repo": "acme/reporting-web", // optional hint for the agent
|
||||
"target_repo": "acme/reporting-web", // optional hint the user supplies at create time
|
||||
"context_excerpts": [
|
||||
// minimal transcript quotes that ground the request
|
||||
// verbatim transcript quotes that ground the request — NOT model paraphrase;
|
||||
// the builder selects them from the real transcript (speaker = resolved display name)
|
||||
{ "speaker": "Customer", "text": "We really need to pull this into our own spreadsheets." },
|
||||
],
|
||||
"source": { "meeting_title": "Acme quarterly sync", "at": 1751299200 },
|
||||
}
|
||||
```
|
||||
|
||||
Field presence: `title`, `problem`, `desired_outcome` are always strings (the builder falls back to
|
||||
the meeting title / `""` on a sparse model reply); `acceptance_criteria` and `context_excerpts` may
|
||||
be empty arrays. Every `context_excerpts[].text` is a verbatim substring of a real transcript
|
||||
segment (the M1 grounding invariant, asserted by the golden-transcript test).
|
||||
|
||||
## `settings.json`
|
||||
|
||||
```jsonc
|
||||
@@ -305,11 +361,12 @@ agent's context.
|
||||
"consent_acknowledged": false, // set true after the one-time consent notice (FR-REC-2)
|
||||
},
|
||||
"llm": {
|
||||
"provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011)
|
||||
"provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011; "openai" not yet wired)
|
||||
"endpoint": "http://localhost:11434",
|
||||
"model": "llama3",
|
||||
"stream": true,
|
||||
// API keys for hosted providers (anthropic|openai) live in the OS credential store, not here.
|
||||
"hosted_ai_acknowledged": false, // one-time "data leaves your device" notice ack (T10.3, ADR-0011)
|
||||
},
|
||||
// Sync target rows live in wa.db (sync_targets); secrets live in the OS credential store.
|
||||
// settings.json only holds the global default. No credentials here (FR-SYNC-6).
|
||||
@@ -323,6 +380,10 @@ agent's context.
|
||||
"expose_recordings": false, // never serve .wav unless explicitly true (FR-MCP-3)
|
||||
},
|
||||
"privacy": { "encrypt_at_rest": false },
|
||||
// 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
|
||||
// is never written here (same invariant as sync credentials, FR-SYNC-6).
|
||||
"calendar": { "graph_enabled": false, "graph_credential_ref": null },
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -15,23 +15,42 @@ each command returns `Result<T, WaError>` where `WaError` carries a `kind` (mach
|
||||
// `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is
|
||||
// deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting.
|
||||
// templateId (Phase 8, T8.1, FR-NOTE-5) picks a NoteTemplate — see list_note_templates below.
|
||||
start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string }): MeetingId
|
||||
// language (T8.7, FR-TRX-4, M4.2): omitted/"auto" requests auto-detection; an ISO-639-1 code
|
||||
// (e.g. "es") forces that language. Falls back to Settings.whisper_language when omitted.
|
||||
// Only takes effect with a multilingual model loaded (ModelInfo.multilingual) — an English-only
|
||||
// model forces "en" regardless (see resolve_language in src-tauri/src/transcription/mod.rs).
|
||||
start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string; language?: string }): MeetingId
|
||||
stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef
|
||||
pause_recording(input: { meetingId: MeetingId }): void
|
||||
resume_recording(input: { meetingId: MeetingId }): void
|
||||
set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1)
|
||||
acknowledge_recording_consent(): void // one-time (FR-REC-2)
|
||||
|
||||
// ---- Live notes (Granola-style redesign, `03-data-model.md`'s manual_notes.json) ----
|
||||
// Both are live-session only (err "no matching active recording" once finalized — post-finalize,
|
||||
// notes.md is the single editable document and `update_notes` is the command for it).
|
||||
update_live_notes(input: { meetingId: MeetingId; markdown: string }): void // freeform notes typed while recording
|
||||
// anchorMs: the clicked transcript segment's start_ms, not its id (survives re-transcription).
|
||||
// text: "" clears that moment's note. Merged into notes.md right after the transcript paragraph
|
||||
// covering anchorMs when the meeting finalizes (notes::MarkdownNotes::merge).
|
||||
set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void
|
||||
|
||||
// ---- Hardware ----
|
||||
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number }
|
||||
set_preferred_backend(input: { backend: BackendId | "auto" }): void
|
||||
|
||||
// ---- Transcription / models ----
|
||||
reprocess_transcript(input: { meetingId: MeetingId; model: string }): void // batch mode (FR-TRX-3)
|
||||
// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it.
|
||||
reprocess_transcript(input: { meetingId: MeetingId; model: string; language?: string }): void // batch mode (FR-TRX-3)
|
||||
// ModelInfo gained `multilingual: boolean` (T8.7, FR-TRX-4, M4.2) — false for `.en` (English-only)
|
||||
// ggml variants, true for the multilingual ones; gates the Settings language picker.
|
||||
list_models(): ModelInfo[]
|
||||
list_diarization_models(): ModelInfo[] // fixed seg+emb pair (T4.7, FR-MODEL-1)
|
||||
download_model(input: { kind: "whisper" | "diar-seg" | "diar-emb"; id: string }): void // emits progress events
|
||||
remove_model(input: { id: string }): void // disambiguated by id, not kind — ids never collide across catalogs
|
||||
// Static catalog of whisper.cpp-recognized ISO-639-1 codes for the Settings language dropdown
|
||||
// (T8.7, FR-TRX-4, M4.2); "Auto-detect" is a frontend-only addition, not in this list.
|
||||
list_whisper_languages(): { code: string; label: string }[]
|
||||
|
||||
// ---- Speakers ----
|
||||
rename_speaker(input: { meetingId: MeetingId; label: string; name: string }): void
|
||||
@@ -44,9 +63,21 @@ map_speaker_to_participant(input: { meetingId: MeetingId; label: string; partici
|
||||
// yet at local-desktop meeting counts) and gained from/to date filters, per
|
||||
// FR-SEARCH-2's "filter by date, tag, or participant".
|
||||
list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[]
|
||||
// Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source
|
||||
// of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment
|
||||
// keeps start_ms/end_ms; the UI renders these as per-line timestamps.
|
||||
get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated)
|
||||
delete_meeting(input: { meetingId: MeetingId }): void
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string
|
||||
// format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json
|
||||
// (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine.
|
||||
// format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio):
|
||||
// YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions
|
||||
// + action items + timestamped transcript. For dropping a meeting into an Obsidian vault.
|
||||
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): string
|
||||
// Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary,
|
||||
// reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5):
|
||||
// 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.
|
||||
update_notes(input: { meetingId: MeetingId; markdown: string }): void
|
||||
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
|
||||
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
|
||||
@@ -58,6 +89,10 @@ list_note_templates(): NoteTemplate[]
|
||||
// Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle
|
||||
// folder) per meeting under destDir. Returns the count actually exported.
|
||||
bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number
|
||||
// Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent
|
||||
// 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.
|
||||
import_meeting_bundle(input: { dir: string }): number
|
||||
|
||||
// ---- LLM / AI provider (ADR-0007/0011) ----
|
||||
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
|
||||
@@ -73,10 +108,27 @@ llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggest
|
||||
pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7)
|
||||
|
||||
// ---- Calendar / .pst ----
|
||||
import_pst(input: { path: string; password?: string }): number // eventsImported; emits pst://progress (FR-CAL-1)
|
||||
// rangeDays: only import events starting within the last N days; omitted imports the full mailbox
|
||||
// history. Bug fix: a long-lived .pst has no natural upper bound on history (every recurring
|
||||
// series expands to its cap, T4.1's RECURRENCE_MAX_OCCURRENCES, plus every one-off entry the file
|
||||
// ever held, e.g. a decade of Outlook's auto-generated yearly holidays) -- unbounded import could
|
||||
// produce tens of thousands of rows. Settings.pst_import_range_days persists the last choice and
|
||||
// applies it to pst_auto_sync's startup re-import too.
|
||||
import_pst(input: { path: string; password?: string; rangeDays?: number }): number // eventsImported; emits pst://progress (FR-CAL-1)
|
||||
list_calendar_events(input: { from?: number; to?: number }): CalendarEvent[]
|
||||
get_calendar_event(input: { eventId: string }): { event: CalendarEvent; participants: Participant[] } // pre-meeting panel + naming dropdown (FR-CAL-3, FR-SPK-4)
|
||||
// olderThanDays omitted deletes every unlinked event ("Delete all"); Some(n) only those starting
|
||||
// more than n days ago. An event attached to a recorded meeting (meetings.calendar_event_id) is
|
||||
// always kept regardless of the choice -- protected reports how many were skipped for that reason.
|
||||
cleanup_calendar_events(input: { olderThanDays?: number }): { deleted: number; protected: number }
|
||||
attach_meeting_to_event(input: { meetingId: MeetingId; eventId: string }): void
|
||||
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6): opt-in, explicit consent (OAuth PKCE
|
||||
// + Microsoft's own consent screen), metadata-only (subject/organizer/start/end/attendees, never
|
||||
// the event body). Not a SyncTarget — begin_graph_calendar_link stores its token separately from
|
||||
// sync_targets and never appears in list_sync_targets.
|
||||
begin_graph_calendar_link(): { authUrl: string } // opens in browser; emits calendar://linked when done
|
||||
import_graph_calendar(input: { from?: number; to?: number }): number // eventsImported; emits calendar://progress
|
||||
disconnect_graph_calendar(): void // best-effort credential cleanup + settings reset
|
||||
|
||||
// ---- Sync / upload (ADR-0010) ---- secrets are passed to add/update but stored only in the OS
|
||||
// credential store; they are NEVER returned by list_sync_targets.
|
||||
@@ -106,6 +158,9 @@ run_agent(input: { briefId: string; tool: "claude" | "codex" | "opencode" | "cop
|
||||
create_issue_from_brief(input: { briefId: string; tracker: "github"; assignCopilot?: boolean }): { url: string } // FR-AGENT-2
|
||||
|
||||
// ---- Settings ----
|
||||
// Settings gained `whisper_language: string | null` (T8.7, FR-TRX-4, M4.2) — the default
|
||||
// transcription language applied at the next start_recording; null = auto-detect. Mirrors
|
||||
// this doc's settings.json `transcription.language` (03-data-model.md).
|
||||
get_settings(): Settings
|
||||
update_settings(input: Partial<Settings>): Settings
|
||||
// Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2).
|
||||
@@ -125,7 +180,7 @@ privacy_self_check(): {
|
||||
## 2. Tauri events (Rust → frontend)
|
||||
|
||||
```ts
|
||||
"recording://state" { meetingId, state: "recording"|"paused"|"stopped", elapsedMs }
|
||||
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
|
||||
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
|
||||
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
|
||||
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2)
|
||||
@@ -135,6 +190,8 @@ privacy_self_check(): {
|
||||
"llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer
|
||||
"model://progress" { id, receivedBytes, totalBytes }
|
||||
"pst://progress" { processed, total }
|
||||
"calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4)
|
||||
"calendar://progress" { processed, total } // MS Graph import (M4.4)
|
||||
"hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4)
|
||||
"recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3)
|
||||
"sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5
|
||||
@@ -154,10 +211,15 @@ indicative (async where I/O-bound).
|
||||
pub trait AudioCapture: Send + Sync {
|
||||
/// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`.
|
||||
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
|
||||
/// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV.
|
||||
fn start_microphone(&self, device_id: Option<&str>, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
|
||||
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
||||
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
|
||||
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
|
||||
}
|
||||
// When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono
|
||||
// frames into the single transcription stream (`list_input_devices` enumerates
|
||||
// mic devices, mirroring `list_audio_devices` for render devices).
|
||||
|
||||
// hardware/mod.rs
|
||||
pub trait HardwareDetector: Send + Sync {
|
||||
@@ -200,9 +262,12 @@ pub trait Diarizer: Send + Sync {
|
||||
}
|
||||
|
||||
// calendar/mod.rs
|
||||
// `attendees()` (a second, separate trait method in the original design) was dropped — every
|
||||
// source (PstSource, GraphSource) lists attendees inline per-appointment, so import() returns
|
||||
// them together (see ADR-0008's update). CalImport gained `from`/`to` (M4.4) for a source that
|
||||
// fetches by date range (Graph's calendarView); PstSource ignores them.
|
||||
pub trait CalendarSource: Send + Sync {
|
||||
fn import(&self, input: CalImport) -> Result<Vec<CalendarEvent>, CalError>; // pst|graph|ics
|
||||
fn attendees(&self, event_id: &str) -> Result<Vec<Participant>, CalError>;
|
||||
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError>; // pst|graph|ics
|
||||
}
|
||||
|
||||
// notes/mod.rs
|
||||
@@ -210,6 +275,9 @@ pub trait NotesRenderer: Send + Sync {
|
||||
fn to_markdown(&self, t: &Transcript, speakers: &[SpeakerInfo], s: Option<&Summary>) -> String;
|
||||
fn export(&self, md: &str, dest: &Path, fmt: ExportFormat) -> Result<PathBuf, NotesError>;
|
||||
}
|
||||
// MarkdownNotes::merge(segments, speakers, manual: &ManualNotes, summary, template) -> String is an
|
||||
// inherent method (not part of the trait — only one renderer needs it): what `stop_recording` calls
|
||||
// instead of `to_markdown` to fold manual_notes.json into the generated notes.md (see 03-data-model.md).
|
||||
|
||||
// sync/mod.rs
|
||||
// One impl per provider; `WebDavTarget` covers Nextcloud/ownCloud/Cloudreve/Seafile/Synology.
|
||||
|
||||
@@ -235,3 +235,152 @@ P10 needs P5; 10b (MCP) is the priority; 10c (push/issue) is later and optional.
|
||||
## Suggested first milestone (thin vertical slice)
|
||||
T1.1 → T1.2 → T1.5 → T1.6 → T2.1 → T2.2 → T2.4 gives a usable "record → live transcript → saved
|
||||
Markdown notes" loop — the smallest thing worth dogfooding.
|
||||
|
||||
---
|
||||
|
||||
## Remaining work — post-v0.2.0 execution plan
|
||||
|
||||
As of **v0.2.0**, Phases 1–9 ship (capture incl. microphone, CPU/NPU/Vulkan transcription,
|
||||
diarization, storage/recovery, notes, local-LLM summaries, PST calendar, UX/a11y,
|
||||
templates/search/tags/export/reminders/encryption, WebDAV + OneDrive sync) in a single universal
|
||||
installer. What remains is **Phase 10 (external AI & agent handoff, ADR-0011)** plus a few
|
||||
breadth/reliability items. This section sequences that work by leverage, dependency, and the
|
||||
privacy invariant. Task IDs reference the Phase 10 list above; finer sub-tasks add a letter suffix.
|
||||
Traits/contracts for all of this already exist in `04-api-contracts.md` (`FeatureBriefBuilder`,
|
||||
`McpServer`, hosted `LlmProvider` impls, `AgentRunner`, `IssueTracker`); tests live in
|
||||
`06-test-strategy.md` (P10 + the cross-cutting egress gate).
|
||||
|
||||
**Status snapshot (what's actually in the tree):**
|
||||
- **Built:** Phases 1–9; `OpenAiCompatProvider` (used as the Phase 5 "custom" endpoint); chunked/
|
||||
resumable upload (M4.1); multi-language transcription (M4.2); `DropboxTarget`/`BoxTarget` (M4.3);
|
||||
MS Graph `CalendarSource` (`GraphSource`, M4.4).
|
||||
- **Stubbed** (commands return `Err(not_implemented(...))`): `create/list/get_feature_brief`,
|
||||
`set_brief_exposed`, `mcp_status`, `set_mcp_enabled`, `set_mcp_scope`, `mcp_access_log`,
|
||||
`run_agent`, `create_issue_from_brief`.
|
||||
- **Skeleton/absent:** `mcp/mod.rs` (trait + `todo!()` only); `AnthropicProvider` (returns
|
||||
"isn't built yet").
|
||||
|
||||
**Sequence:** M1 → M2 (briefs are the MCP payload); **M3 can run in parallel** with M1/M2; M4 is
|
||||
opportunistic; **M5 is last and optional**.
|
||||
|
||||
### M1 — Feature briefs (do first; standalone value) → FR-MCP-4 (T10.6)
|
||||
Needs only the already-built LLM, delivers value before any MCP transport (view/copy a brief), and
|
||||
is the exact payload M2 serves. **No new egress** (uses the configured `LlmProvider`).
|
||||
|
||||
**Already scaffolded — do NOT re-create:** IPC types `FeatureBrief`/`FeatureBriefInfo`/
|
||||
`ContextExcerpt` (`models.rs`); `api.ts` bindings `createFeatureBrief`/`listFeatureBriefs`/
|
||||
`getFeatureBrief`/`setBriefExposed`; the four commands registered in `lib.rs`; DB tables
|
||||
`feature_briefs` + `mcp_access_log` (`migrations/0003_ai_mcp.sql`); the `briefs/<id>.json` schema
|
||||
(`03-data-model.md`) and the `FeatureBriefBuilder` trait (`04-api-contracts.md`). The four command
|
||||
bodies today return `Err(not_implemented(...))` — M1 fills them in.
|
||||
|
||||
- `[M1.1]` **Storage methods** (`Store` trait + `SqliteStore`, `storage/mod.rs`) over
|
||||
`feature_briefs`; row struct `FeatureBriefRow { id, meeting_id, title, target_repo: Option<String>,
|
||||
path, exposed: bool, created_at }`. Deletion cascades via the meeting FK (existing `delete_meeting`
|
||||
already drops the row + folder). **S**
|
||||
- `async fn insert_feature_brief(&self, row: FeatureBriefRow) -> Result<(), StoreError>;`
|
||||
- `async fn list_feature_briefs(&self, meeting_id: Option<&MeetingId>) -> Result<Vec<FeatureBriefInfo>, StoreError>;` (newest first)
|
||||
- `async fn get_feature_brief_row(&self, id: &str) -> Result<FeatureBriefRow, StoreError>;` (resolves `path`)
|
||||
- `async fn set_brief_exposed(&self, id: &str, exposed: bool) -> Result<(), StoreError>;`
|
||||
- `[M1.2]` **LLM completion primitive** (`llm/mod.rs`) — one non-streaming method on `LlmProvider`
|
||||
(mirrors `suggest_tags`), implemented for `OllamaProvider` + `OpenAiCompatProvider` now (Anthropic
|
||||
lands in M3): `async fn complete(&self, system: &str, user: &str) -> Result<String, LlmError>;`. **S**
|
||||
- `[M1.3]` **`FeatureBriefBuilder`** (new `briefs` module) — the distiller. **M**
|
||||
- *Prompt contract* (system, reuse the `RESPONSE_FORMAT_INSTRUCTIONS` pattern): "Distill this
|
||||
transcript into an implementation brief for a coding agent. Respond in Markdown with exactly, in
|
||||
order: `## Title` (one line), `## Problem`, `## Desired Outcome`, `## Acceptance Criteria` (a
|
||||
`- ` bullet list, one testable criterion per line). Be concrete and terse; invent nothing not in
|
||||
the transcript; no other sections." *User*: `build_prompt`-style metadata + optional `target_repo`
|
||||
hint + `"Transcript:\n" + transcript`.
|
||||
- *Parser* `parse_brief(md) -> BriefFields { title, problem, desired_outcome, acceptance_criteria }`
|
||||
— mirror `parse_summary`/`bullet_text`; missing section → `""`/`[]`; title fallback = meeting title.
|
||||
- *`context_excerpts` (grounding, verbatim)*: tokenize `problem + acceptance_criteria`, score each
|
||||
transcript segment by keyword overlap, take the top ≤5 of length ≥ ~40 chars (fallback: first 3
|
||||
substantive segments); `speaker` = resolved display name. `// ponytail: keyword-overlap select;
|
||||
upgrade to embedding similarity if excerpts feel off`.
|
||||
- `[M1.4]` **Command bodies** (`commands.rs`, replace the four stubs; keep names/args so `api.ts` is
|
||||
unchanged — add `state: State<'_, AppState>`, which Tauri injects). **M**
|
||||
- `create_feature_brief(state, meeting_id: MeetingId, target_repo: Option<String>) -> WaResult<FeatureBrief>`
|
||||
— reject the currently-recording meeting; `load_settings()` + `llm_provider_from_settings()`
|
||||
(error if none); load transcript via `state.store`; run the builder; assemble `BriefFile { schema:
|
||||
1, id, meeting_id, generated_at, provider, model, …fields, source }`; `vault::seal` → write
|
||||
`briefs/<id>.json` under `meeting_dir`; `store.insert_feature_brief(row)` (`exposed=false`);
|
||||
return the IPC `FeatureBrief`. **Write the file + row only after a successful distill** (no partial
|
||||
artifacts on LLM failure).
|
||||
- `list_feature_briefs(state, meeting_id: Option<MeetingId>) -> WaResult<Vec<FeatureBriefInfo>>`
|
||||
- `get_feature_brief(state, id: String) -> WaResult<FeatureBrief>` (row → `vault::open(path)` → subset)
|
||||
- `set_brief_exposed(state, id: String, exposed: bool) -> WaResult<()>`
|
||||
- `[M1.5]` **UI** (`SummaryPanel.svelte` or a new "Feature briefs" block; existing bindings, no new
|
||||
client code): "Create feature brief" button + optional target-repo input; list via
|
||||
`listFeatureBriefs(meetingId)`; viewer with **copy-as-Markdown** and **copy-as-JSON**; `exposed`
|
||||
toggle wired to `setBriefExposed` but shown as "available when the MCP server is on" until M2. **M**
|
||||
- `[M1.6]` **Tests — golden transcript** (see `06-test-strategy.md` P10): `parse_brief` unit
|
||||
(golden reply → fields; empty/malformed → empty, no panic); builder over a golden transcript with a
|
||||
`MockLlmProvider` (no network) asserting the fields, non-empty `acceptance_criteria`, and the
|
||||
**grounding invariant** (every excerpt text is a verbatim substring of a transcript segment);
|
||||
command-level: LLM off/unreachable → `Err`, nothing written. **S**
|
||||
|
||||
- **Acceptance (M1 done):** on a finished meeting with an LLM configured, "Create feature brief"
|
||||
produces a schema-valid, vault-sealed `briefs/<id>.json` + a `feature_briefs` row that lists and
|
||||
re-opens; excerpts are verbatim from the transcript; with the LLM off/unreachable the command errors
|
||||
cleanly and writes nothing partial; the golden-transcript unit tests pass. No new egress; `exposed`
|
||||
defaults off.
|
||||
|
||||
### M2 — MCP server (the differentiator) → FR-MCP-1/2/3/5/6/7 (T10.4/10.5/10.7/10.8/10.9)
|
||||
The unique capability (meeting → coding-agent handoff) and a privacy *reinforcement*: inbound on
|
||||
loopback, **zero added egress**.
|
||||
- `[M2.1]` `mcp` module on `rmcp`: loopback bind + token gate; Streamable HTTP (`/mcp`) + stdio
|
||||
adapter; implement `McpServer::start/stop` (replace `todo!()`). **L** → FR-MCP-1/6, NFR-SEC-5
|
||||
- `[M2.2]` Tools-first surface: `list_recent_meetings`, `get_transcript`, `get_action_items`,
|
||||
`get_feature_brief`. **M** → FR-MCP-2
|
||||
- `[M2.3]` Scope control `set_mcp_scope(none|selected|all)`; recordings never served unless
|
||||
explicitly allowed; enforce in every tool handler. **M** → FR-MCP-3
|
||||
- `[M2.4]` Lifecycle: `mcp_status`/`set_mcp_enabled` (replace stubs); persist enabled/scope in
|
||||
settings, **token in the OS credential store** (never `settings.json`). **M** → FR-MCP-1
|
||||
- `[M2.5]` Disclosure UI ("connected agents may forward data") + `mcp_access_log` rows /
|
||||
`mcp://access` events on every tool read. **M** → FR-MCP-5
|
||||
- `[M2.6]` Privacy panel shows MCP state and confirms it adds no egress; extend
|
||||
`privacy_self_check`. **S** → FR-MCP-7, FR-SEC-2
|
||||
- **Acceptance:** an in-process MCP client gets a schema-valid brief; server binds loopback only
|
||||
(assert a non-loopback bind is refused) and requires a token; **the egress test is unchanged with
|
||||
MCP on** (merge blocker, FR-MCP-7); recordings not served unless allowed; every read logged.
|
||||
- **Depends on:** M1 (a served tool). Guardrail: the cross-cutting egress gate must stay green.
|
||||
|
||||
### M3 — Hosted AI providers (finish 10a) → FR-AI-1/2/3 (T10.1/10.2/10.3)
|
||||
Commodity but low-effort (OpenAI-compat already exists); gives a cloud-summary choice and pairs with
|
||||
M1 (a brief can be distilled by a hosted model). Adds allowlisted egress **by design**.
|
||||
- `[M3.1]` Implement `AnthropicProvider` (`/v1/messages`, `x-api-key`, streaming) behind
|
||||
`LlmProvider`; `is_local() == false`. **M** → FR-AI-1
|
||||
- `[M3.2]` `set_llm_provider`: store the API key in the OS credential store via `credential_ref`
|
||||
(never settings/DB); add the host to the settings-derived egress allowlist. **M** → FR-AI-2, NFR-SEC-4
|
||||
- `[M3.3]` Third-party "data leaves your device" banner + one-time acknowledgment; per-use provider
|
||||
selection + active-provider display wherever a summary is generated. **S** → FR-AI-2/3
|
||||
- **Acceptance:** summaries route to mocked OpenAI-compat *and* Anthropic endpoints with correct
|
||||
shapes; key read only from the credential store; host on the allowlist only when configured; banner
|
||||
shown before first hosted use.
|
||||
|
||||
### M4 — Reliability & breadth (opportunistic, by value)
|
||||
- `[M4.1]` **Chunked/resumable upload** (T9.2 refinement) — *top reliability item*: recordings are
|
||||
now native-quality (~50–100 MB) and `put()` buffers the whole file in memory; OneDrive/Graph caps a
|
||||
single PUT at 250 MB. Stream from disk in chunks (Nextcloud chunked upload + Graph upload session).
|
||||
**M** → FR-SYNC-2/5
|
||||
- `[M4.2]` Multi-language transcription (T8.7): multilingual model option; whisper language param
|
||||
(select/auto); persist per-meeting `language`; UI localization scaffold. **M** → FR-TRX-4
|
||||
- `[M4.3]` Dropbox/Box upload targets (T9.10): implement `DropboxTarget`/`BoxTarget` `SyncTarget`
|
||||
impls (OAuth is already wired). **M** → FR-SYNC-9
|
||||
- `[M4.4]` MS Graph calendar source (T8.9) behind `CalendarSource` (`GraphSource`), consented
|
||||
(OAuth PKCE), metadata-only. **L** → FR-CAL-6 (Could) — **shipped**.
|
||||
|
||||
### M5 — Push handoff (Layer 3, last & optional) → FR-AGENT-1/2 (Could) (T10.10/10.11)
|
||||
Only on demand — the MCP handoff (M2) already covers the agent use case (agents *pull* context).
|
||||
- `[M5.1]` `AgentRunner`: spawn `claude -p` / `codex exec` / `opencode run` / `copilot` headless
|
||||
against a chosen repo from a brief; stream output (`run_agent`). **L** → FR-AGENT-1
|
||||
- `[M5.2]` `IssueTracker`: create a GitHub issue from a confirmed action item / brief; optional
|
||||
Copilot-cloud assign (`create_issue_from_brief`). Third-party egress: off/labeled/allowlisted. **M**
|
||||
→ FR-AGENT-2
|
||||
|
||||
**Privacy checkpoints (gate every milestone):** the egress self-test stays green (FR-SEC-1); enabling
|
||||
MCP adds **no** allowlist host (FR-MCP-7); hosted AI/agent handoff adds only the explicitly configured
|
||||
host; all secrets (AI keys, MCP token, OAuth tokens) live only in the OS credential store
|
||||
(NFR-SEC-4). Plus the standing gates: `cargo fmt`/`clippy -D warnings`, Prettier/ESLint/`tsc`, and a
|
||||
docs update whenever a command/event contract changes (CLAUDE.md).
|
||||
|
||||
@@ -114,7 +114,14 @@ is green and the cross-cutting gates pass.
|
||||
requires a token, and **opens no outbound socket** — the egress test is unchanged with MCP on (FR-MCP-7,
|
||||
NFR-SEC-5). Recordings are not served unless `expose_recordings` is true (FR-MCP-3).
|
||||
- Audit: every tool call appends an `mcp_access_log` row / `mcp://access` event (FR-MCP-5).
|
||||
- Unit: `FeatureBriefBuilder` produces problem/outcome/acceptance-criteria from a golden transcript.
|
||||
- Unit (M1 — feature briefs, no network): (1) `parse_brief` splits a golden
|
||||
`## Title/## Problem/## Desired Outcome/## Acceptance Criteria` reply into the right fields, and an
|
||||
empty/malformed reply yields empty fields without panicking. (2) `FeatureBriefBuilder` over a golden
|
||||
transcript, driven by a `MockLlmProvider` that returns a fixed sectioned reply, produces a
|
||||
schema-valid `FeatureBrief` with non-empty `acceptance_criteria` and satisfies the **grounding
|
||||
invariant**: every `context_excerpts[].text` is a verbatim substring of some transcript segment
|
||||
(never model paraphrase). (3) Command-level: `create_feature_brief` with the LLM off/unreachable
|
||||
returns `Err` and writes no `briefs/*.json` and no `feature_briefs` row (no partial artifacts).
|
||||
- (10c, when built) push: `AgentRunner` invokes a stub CLI with the brief; `IssueTracker` creates a
|
||||
mocked GitHub issue and (optional) Copilot assignment (FR-AGENT-1/2).
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# i18n migration tracking
|
||||
|
||||
Working doc for the incremental UI-string translation effort. The i18n **mechanism**
|
||||
is done; this tracks moving the app's remaining hardcoded strings into the translation
|
||||
files, one batch at a time. Tick boxes as views are converted.
|
||||
|
||||
> **Status (2026-07-12): migration complete.** All views and components (Batches A–F) are
|
||||
> converted; `en.json` holds ~506 keys. Every user-facing English string flows through
|
||||
> `t()`. What remains is intentionally-untranslated data (see the "Not translated" section)
|
||||
> — plus the actual work of adding a second language, which is now just translating
|
||||
> `en.json` into a new `<code>.json`.
|
||||
|
||||
- **Engine:** hand-rolled, zero-dependency. `src/lib/i18n/index.svelte.ts`
|
||||
- **Baseline dictionary:** `src/lib/i18n/en.json` (English is the source-of-truth key set
|
||||
**and** the fallback for any missing key)
|
||||
- **Selector:** Settings → Language (`section === "language"`)
|
||||
- **Scope decision:** display language is separate from the transcription `whisper_language`
|
||||
setting — don't conflate them.
|
||||
|
||||
## What we translate (and what we never do)
|
||||
|
||||
**Translate: UI chrome only** — fixed labels, buttons, headings, placeholders,
|
||||
empty/loading/status states, tooltips, and app-generated default *labels*.
|
||||
|
||||
**Never translate: user-authored content.** Meeting titles, tag names, notes, transcript
|
||||
text, search snippets, dates — these are bound straight from data (`item.title`, tag
|
||||
values, `s.text`, …) and stay exactly as the user wrote them. If a string comes from the
|
||||
user or the recording, it does not get a key.
|
||||
|
||||
So "convert view X" always means "key its fixed labels", never "touch its content". Most
|
||||
views are mostly content with a thin shell of labels — MeetingsList, for example, is ~10
|
||||
fixed labels (search placeholder, empty states, filter/bulk-export labels) wrapped around
|
||||
a list whose rows are pure user data.
|
||||
|
||||
> Caveat — the `"Untitled meeting"` **default title is generated in the Rust backend**, not
|
||||
> the frontend, so it never reaches `t()`. Localizing app-generated defaults is a separate
|
||||
> backend decision, out of scope for this frontend effort.
|
||||
|
||||
## How to convert a string (the pattern)
|
||||
|
||||
1. Add a key to `en.json`. Naming: `<area>.<subarea>.<name>`, dotted, grouped by view —
|
||||
e.g. `nav.recording`, `settings.transcription.title`, `meetings.empty`.
|
||||
2. Replace the literal in markup with `{t("key")}` (import `t` from `../i18n/index.svelte`).
|
||||
3. Dynamic bits use placeholders: `t("meetings.count", { n })` against
|
||||
`"meetings.count": "{n} meetings"`. Handle plurals with a caller-side ternary for now
|
||||
(`n === 1 ? t("...one") : t("...many")`) — the engine is intentionally simple.
|
||||
4. Attributes translate the same way: `title={t("...")}`, `aria-label={t("...")}`,
|
||||
`placeholder={t("...")}`.
|
||||
5. `npm run check` must stay at 0 errors/warnings.
|
||||
|
||||
> When adding a **new language** (not covered by this doc's batches): copy `en.json` to
|
||||
> `<code>.json`, translate it, and register it in `DICTS` + `LOCALES` in
|
||||
> `index.svelte.ts`. That one file is the whole job.
|
||||
|
||||
## Done
|
||||
|
||||
- [x] i18n engine + `en.json` baseline + Settings language selector (branch
|
||||
`feature_chore_bug_007`)
|
||||
- [x] `Settings.svelte` — nav labels (`nav.*`), the Language section
|
||||
(`settings.language.*`), and the Transcription Language section
|
||||
(`settings.transcription.*`)
|
||||
|
||||
- [x] `src/App.svelte` — app shell chrome (`app.*`): header (tagline, template
|
||||
picker, record/stop/cancel/add-meeting, recording status, retention, backend,
|
||||
device-notice), Settings button, consent + vault-unlock dialogs, pane toggles,
|
||||
splitter labels, and JS strings (discard confirm, SR announcements, vault error).
|
||||
- [x] `src/lib/views/MeetingsList.svelte` — labels only (`meetings.*`): search/filter/
|
||||
bulk-export controls, empty/loading/status states, status badges (via
|
||||
`statusLabel`), resume + delete, delete-confirm, and the pluralized export result.
|
||||
List rows (titles/tags/dates/snippets) left as user data.
|
||||
- [x] `src/lib/views/TranscriptNotes.svelte` — transcript + notes chrome (`transcript.*`,
|
||||
`notes.*`): pane headings/toggles, reprocess controls, notes toolbar (bold/heading/
|
||||
list/preview), export button tooltips + dialog filter names, empty states, segment
|
||||
tooltips + note placeholders. Transcript/notes/speaker text left as user data.
|
||||
- [x] `src/lib/views/SummaryPanel.svelte` — all panel chrome (`summary.*`): section
|
||||
headings (Recording/Sync/Tags/Summary/Briefs/Action items/Calendar/Participants/
|
||||
Speakers), buttons, placeholders, empty states, provider labels (via `providerLabel`;
|
||||
brand names kept literal), action-item + brief + speaker controls. Summary text,
|
||||
tags, brief content, participant/speaker names left as user data.
|
||||
- [x] `src/lib/views/Settings.svelte` — **all sections** done (`settings.*`): the modal
|
||||
shell (dialog title, Close), plus recording, hardware (incl. models/diarization),
|
||||
storage (+ export/import status), calendar (.pst import/cleanup/events), sync
|
||||
(targets + WebDAV/OAuth forms), AI (provider + Ollama advanced params), MCP
|
||||
(transport/scope/token/access log), privacy (egress self-check + vault), about.
|
||||
OLLAMA_OPTIONS param catalog labels/help left as data (config catalog, like model
|
||||
ids); example URLs/model-id placeholders left literal.
|
||||
|
||||
- [x] Components (Batch F): `ConsentNotice` + `HostedAiBanner` (legal/consent copy,
|
||||
`consent.*` / `hosted.*`), `ThemeToggle` (`theme.*`), `ImportMeeting` (`import.*`),
|
||||
`TagChip` (`tagchip.*`), `LevelMeter` (`levelmeter.*`). `Splitter`'s `label` is
|
||||
caller-supplied and already translated by the parent; no strings of its own.
|
||||
|
||||
## Outstanding — suggested batches
|
||||
|
||||
_None — all batches complete._ The section below is kept as a record of the plan.
|
||||
|
||||
Ordered roughly by user-visibility ÷ effort. Sizes are rough (line count / labeled
|
||||
attributes) to help portion the work, not exact string counts. A file isn't "done" until
|
||||
its visible text **and** its `title`/`aria-label`/`placeholder` attributes are keyed.
|
||||
|
||||
### ~~Batch A — app shell~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch B — meetings list~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch C — transcript & notes~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch D — summary panel~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
### ~~Batch E — Settings, all sections~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
All nine sections + the modal shell converted, one commit per section. Moved to the Done
|
||||
list above.
|
||||
|
||||
### ~~Batch F — components~~ ✅ done (branch `feature_chore_bug_007`)
|
||||
Moved to the Done list above.
|
||||
|
||||
## Not translated (intentional)
|
||||
|
||||
- **User-authored content** — meeting titles, tag names, notes, transcript text, search
|
||||
snippets. Bound from data; stays as the user wrote it (see "What we translate" above).
|
||||
- App-generated defaults created in the backend (e.g. `"Untitled meeting"`) — a separate
|
||||
backend concern; the frontend `t()` never sees them.
|
||||
- Backend / Rust error messages surfaced via `WaError` — out of scope for the frontend
|
||||
`t()`; revisit only if we localize command errors.
|
||||
- Provider/proper names (Ollama, Anthropic, OpenAI, Obsidian, WebDAV, WhispAssist),
|
||||
model ids, ISO language codes.
|
||||
- Console/`tracing` logs.
|
||||
|
||||
## Batch log
|
||||
|
||||
Record each landed batch here (date / branch / commit) so progress is auditable.
|
||||
|
||||
| Date | Batch | Branch / commit | Notes |
|
||||
|------|-------|-----------------|-------|
|
||||
| 2026-07-12 | Infra + Settings nav/language/transcription | `feature_chore_bug_007` | engine + en.json + selector |
|
||||
| 2026-07-12 | Batch A (App shell) + Batch B (MeetingsList) | `feature_chore_bug_007` | +~60 keys; `app.*`, `meetings.*` |
|
||||
| 2026-07-12 | Batch C (TranscriptNotes) + Batch D (SummaryPanel) | `feature_chore_bug_007` | +~120 keys; `transcript.*`, `notes.*`, `summary.*`; en.json now 198 keys |
|
||||
| 2026-07-12 | Batch E (Settings, all 9 sections + shell) | `feature_chore_bug_007` | +~270 keys; `settings.*`; en.json now 470 keys; one commit per section |
|
||||
| 2026-07-12 | Batch F (components) | `feature_chore_bug_007` | +~36 keys; `consent.*`/`hosted.*`/`theme.*`/`import.*`/`tagchip.*`/`levelmeter.*`; en.json now 506 keys — migration complete |
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "whispassist",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"description": "Privacy-first, fully local Windows meeting assistant.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
Generated
+85
-7
@@ -71,7 +71,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -475,7 +475,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -485,7 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
@@ -626,6 +637,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -1492,6 +1512,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1795,6 +1816,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -1808,6 +1835,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec 1.15.2",
|
||||
@@ -2209,7 +2237,9 @@ version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"log",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3138,7 +3168,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -3437,6 +3467,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20 0.10.1",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -3475,6 +3516,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3697,18 +3744,28 @@ checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4156,7 +4213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4167,7 +4224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4543,6 +4600,19 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-stream"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -5973,15 +6043,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whispassist"
|
||||
version = "0.1.5"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"docx-rs",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
"hound",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"keyring",
|
||||
"ort",
|
||||
"printpdf",
|
||||
@@ -6000,6 +6076,8 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
|
||||
+31
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whispassist"
|
||||
version = "0.1.5"
|
||||
version = "0.4.0"
|
||||
description = "Privacy-first, fully local Windows meeting assistant"
|
||||
authors = ["WhispAssist contributors"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -23,10 +23,13 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util", "net"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# Local<->UTC, DST-aware — needed for calendar recurrence (T6.2); already in
|
||||
# the dependency tree transitively (sqlx), this just promotes it to direct.
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
# storage
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] }
|
||||
@@ -43,8 +46,27 @@ argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
getrandom = "0.2"
|
||||
zeroize = "1" # wipe key material from memory on lock
|
||||
keyring = { version = "3", optional = true } # OS credential store (sync + AI creds)
|
||||
rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011)
|
||||
keyring = { version = "3", optional = true, features = ["windows-native"] } # OS credential store (sync + AI creds); windows-native = real Credential Manager (else keyring 3.x uses a no-op mock store)
|
||||
# MCP server (ADR-0011). `client`/`transport-streamable-http-client-reqwest`
|
||||
# are only ever constructed by this crate's own in-process tests (an actual
|
||||
# MCP client talking to our loopback server) -- WA never opens an outbound
|
||||
# MCP connection at runtime, so this adds no egress (FR-MCP-7).
|
||||
rmcp = { version = "0.16", optional = true, features = [
|
||||
"server", "transport-streamable-http-server", "transport-io",
|
||||
"client", "transport-streamable-http-client-reqwest",
|
||||
] }
|
||||
# Low-level HTTP glue for the Streamable HTTP transport: `rmcp`'s
|
||||
# `StreamableHttpService` is a bare `tower_service::Service`, so something has
|
||||
# to actually accept TCP connections and run HTTP/1 on top of it. All four
|
||||
# versions are already in Cargo.lock transitively (via reqwest/tauri), so this
|
||||
# just promotes them to direct deps -- no new crates.
|
||||
hyper = { version = "1", optional = true, features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1", optional = true, features = ["tokio"] }
|
||||
http-body-util = { version = "0.1", optional = true }
|
||||
http = { version = "1", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
tower-service = { version = "0.3", optional = true }
|
||||
tokio-util = { version = "0.7", optional = true }
|
||||
|
||||
# audio / transcription / diarization / calendar are integrated per-phase and are
|
||||
# feature-gated so the CPU-only build always compiles (NFR-MNT-4).
|
||||
@@ -103,7 +125,11 @@ pst = [] # shells out to readpst (libpst) — no cra
|
||||
# Phase 9
|
||||
sync = ["dep:keyring"] # remote upload (WebDAV + OAuth providers)
|
||||
# Phase 10
|
||||
mcp = ["dep:rmcp", "dep:keyring"] # WhispAssist as an MCP server + hosted-AI creds
|
||||
mcp = [
|
||||
"dep:rmcp", "dep:keyring", "dep:hyper", "dep:hyper-util",
|
||||
"dep:http-body-util", "dep:http", "dep:bytes", "dep:tower-service",
|
||||
"dep:tokio-util",
|
||||
] # WhispAssist as an MCP server + hosted-AI creds
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # optimize for size — keep the binary small (NFR-RES-1)
|
||||
|
||||
@@ -14,5 +14,51 @@ fn main() {
|
||||
println!("cargo:rustc-env=WA_GIT_HASH={hash}");
|
||||
println!("cargo:rerun-if-changed=../.git/logs/HEAD");
|
||||
|
||||
// The `vulkan` build links `vulkan-1.dll` at load time, so the exe won't
|
||||
// launch on a machine that lacks the Vulkan loader (no GPU driver / bare VM).
|
||||
// Bundling the redistributable loader (Apache-2.0) next to the exe makes the
|
||||
// single universal installer start everywhere — with no GPU it simply reports
|
||||
// zero devices and we fall back to CPU. Copied both next to the built exe (so
|
||||
// `tauri dev`/`cargo run` work) and into the crate dir where the bundler picks
|
||||
// it up as a resource (see tauri.vulkan.conf.json).
|
||||
if std::env::var_os("CARGO_FEATURE_VULKAN").is_some() {
|
||||
stage_vulkan_loader();
|
||||
}
|
||||
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
/// Locate `vulkan-1.dll` (Vulkan SDK first, then System32) and copy it beside
|
||||
/// the compiled exe and into the crate dir for bundling. Warns rather than fails
|
||||
/// so a dev build on a machine with the loader already on PATH still succeeds.
|
||||
fn stage_vulkan_loader() {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
let source = std::env::var_os("VULKAN_SDK")
|
||||
.map(|sdk| Path::new(&sdk).join("Bin").join("vulkan-1.dll"))
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
let sys = PathBuf::from(r"C:\Windows\System32\vulkan-1.dll");
|
||||
sys.exists().then_some(sys)
|
||||
});
|
||||
let Some(source) = source else {
|
||||
println!(
|
||||
"cargo:warning=vulkan feature is on but vulkan-1.dll wasn't found \
|
||||
(set VULKAN_SDK); the installer won't bundle the Vulkan loader"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Beside the exe: OUT_DIR is target/<profile>/build/<pkg>-<hash>/out, so three
|
||||
// parents up is target/<profile> (correct even under CARGO_TARGET_DIR=C:\wt).
|
||||
if let Some(out_dir) = std::env::var_os("OUT_DIR") {
|
||||
if let Some(exe_dir) = Path::new(&out_dir).ancestors().nth(3) {
|
||||
let _ = std::fs::copy(&source, exe_dir.join("vulkan-1.dll"));
|
||||
}
|
||||
}
|
||||
// Into the crate dir for the bundler resource.
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||
if let Err(e) = std::fs::copy(&source, Path::new(&manifest_dir).join("vulkan-1.dll")) {
|
||||
println!("cargo:warning=failed to stage vulkan-1.dll for bundling: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Bug fix: search (FR-SEARCH-1) never covered summary or tags -- meeting_fts
|
||||
-- only ever had title/transcript_text/notes_text columns. `meeting_fts` is a
|
||||
-- derived index (rebuilt from meetings/transcript.json/notes.md/summary.json/
|
||||
-- tags, never a source of truth), so dropping and recreating it is safe: no
|
||||
-- data loss, and `backfill_fts` repopulates every meeting on next startup
|
||||
-- since a freshly created table has no rows for anything yet.
|
||||
DROP TABLE meeting_fts;
|
||||
|
||||
CREATE VIRTUAL TABLE meeting_fts USING fts5(
|
||||
meeting_id UNINDEXED,
|
||||
title,
|
||||
transcript_text,
|
||||
notes_text,
|
||||
summary_text,
|
||||
tags_text,
|
||||
tokenize = 'porter unicode61'
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Bug fix: prior imports could accumulate many duplicate rows for the same
|
||||
-- real calendar event on every re-import -- either because the (source,
|
||||
-- raw_uid) unique index (added in 0005) never retroactively deduped rows
|
||||
-- that existed before it, or because an event with no UID in its source data
|
||||
-- got `raw_uid = NULL`, which that index's `WHERE raw_uid IS NOT NULL` clause
|
||||
-- explicitly exempts from uniqueness, so it duplicated on every single
|
||||
-- re-import forever. This collapses whatever's already in the table, then
|
||||
-- backfills a stable content-based key for anything still missing a UID so
|
||||
-- future re-imports resolve to the same row instead of minting a new one
|
||||
-- (see calendar::content_uid in src/calendar/mod.rs, which produces the
|
||||
-- identical 'content:subject|organizer|starts_at|ends_at' format used here).
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Drop the index first: the backfill below can momentarily produce rows that
|
||||
-- share a (source, raw_uid) pair before they're deduped a few statements
|
||||
-- later, which the index would reject mid-UPDATE.
|
||||
DROP INDEX IF EXISTS idx_calendar_events_source_uid;
|
||||
|
||||
UPDATE calendar_events
|
||||
SET raw_uid = 'content:' || COALESCE(subject, '') || '|' || COALESCE(organizer, '')
|
||||
|| '|' || COALESCE(starts_at, 0) || '|' || COALESCE(ends_at, 0)
|
||||
WHERE raw_uid IS NULL;
|
||||
|
||||
-- One survivor per (source, raw_uid) group: whichever row a meeting is
|
||||
-- already attached to (so `attach_meeting_to_event` links don't break), else
|
||||
-- the lexicographically-first id (arbitrary but deterministic).
|
||||
CREATE TEMP TABLE calendar_event_survivors AS
|
||||
SELECT source, raw_uid, MIN(id) AS keep_id
|
||||
FROM calendar_events
|
||||
GROUP BY source, raw_uid;
|
||||
|
||||
UPDATE calendar_event_survivors
|
||||
SET keep_id = (
|
||||
SELECT m.calendar_event_id FROM meetings m
|
||||
JOIN calendar_events ce ON ce.id = m.calendar_event_id
|
||||
WHERE ce.source = calendar_event_survivors.source
|
||||
AND ce.raw_uid = calendar_event_survivors.raw_uid
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM meetings m
|
||||
JOIN calendar_events ce ON ce.id = m.calendar_event_id
|
||||
WHERE ce.source = calendar_event_survivors.source
|
||||
AND ce.raw_uid = calendar_event_survivors.raw_uid
|
||||
);
|
||||
|
||||
-- Repoint any meeting attached to a duplicate that's about to be deleted
|
||||
-- onto the group's survivor instead.
|
||||
UPDATE meetings
|
||||
SET calendar_event_id = (
|
||||
SELECT s.keep_id FROM calendar_event_survivors s
|
||||
JOIN calendar_events ce ON ce.source = s.source AND ce.raw_uid = s.raw_uid
|
||||
WHERE ce.id = meetings.calendar_event_id
|
||||
)
|
||||
WHERE calendar_event_id IN (
|
||||
SELECT ce.id FROM calendar_events ce
|
||||
JOIN calendar_event_survivors s ON ce.source = s.source AND ce.raw_uid = s.raw_uid
|
||||
WHERE ce.id != s.keep_id
|
||||
);
|
||||
|
||||
-- Drop the duplicates (cascades to calendar_event_participants).
|
||||
DELETE FROM calendar_events
|
||||
WHERE id NOT IN (SELECT keep_id FROM calendar_event_survivors);
|
||||
|
||||
DROP TABLE calendar_event_survivors;
|
||||
|
||||
CREATE UNIQUE INDEX idx_calendar_events_source_uid ON calendar_events(source, raw_uid)
|
||||
WHERE raw_uid IS NOT NULL;
|
||||
+870
-78
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,574 @@
|
||||
//! Feature-brief distiller (Phase 10 M1, ADR-0011, FR-MCP-4/T10.6). Turns a
|
||||
//! finished meeting's transcript into an agent-ready spec via the configured
|
||||
//! `LlmProvider` — no new egress, no new dependency: it's the same provider
|
||||
//! `generate_summary` already talks to.
|
||||
//!
|
||||
//! The distillation itself (`distill`) is a plain function over an
|
||||
//! `LlmProvider` + transcript data, independent of `Store` — that's what lets
|
||||
//! the golden-transcript test exercise it with a `MockLlmProvider` and no
|
||||
//! database at all. `LlmFeatureBriefBuilder` is the thin `Store`-aware
|
||||
//! adapter the `FeatureBriefBuilder` trait (`docs/04-api-contracts.md`)
|
||||
//! describes, used by `commands::create_feature_brief`.
|
||||
|
||||
use crate::llm::{bullet_text, LlmError, LlmProvider};
|
||||
use crate::models::{ContextExcerpt, FeatureBrief, MeetingId, SpeakerInfo, TranscriptSegment};
|
||||
use crate::storage::{Store, StoreError};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BriefError {
|
||||
#[error("storage error: {0}")]
|
||||
Store(#[from] StoreError),
|
||||
#[error("llm error: {0}")]
|
||||
Llm(#[from] LlmError),
|
||||
}
|
||||
|
||||
/// Builds the agent-ready spec from a transcript via the configured
|
||||
/// `LlmProvider` (`docs/04-api-contracts.md`).
|
||||
#[async_trait]
|
||||
pub trait FeatureBriefBuilder: Send + Sync {
|
||||
async fn build(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
target_repo: Option<&str>,
|
||||
) -> Result<FeatureBrief, BriefError>;
|
||||
}
|
||||
|
||||
/// System prompt contract (mirrors `llm::RESPONSE_FORMAT_INSTRUCTIONS`):
|
||||
/// exactly four sections, in order, nothing invented beyond the transcript.
|
||||
const BRIEF_INSTRUCTIONS: &str = "Distill this transcript into an implementation brief for a \
|
||||
coding agent. Respond in Markdown with exactly, in order: \"## Title\" (one line), \
|
||||
\"## Problem\", \"## Desired Outcome\", \"## Acceptance Criteria\" (a \"- \" bullet list, one \
|
||||
testable criterion per line). Be concrete and terse; invent nothing not in the transcript; no \
|
||||
other sections.";
|
||||
|
||||
/// Parsed reply, before assembly into the IPC/file shapes.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BriefFields {
|
||||
pub title: String,
|
||||
pub problem: String,
|
||||
pub desired_outcome: String,
|
||||
pub acceptance_criteria: Vec<String>,
|
||||
}
|
||||
|
||||
/// Assembles the (system, user) prompt pair — metadata + optional
|
||||
/// `target_repo` hint + the transcript, mirroring `commands::build_prompt`'s
|
||||
/// shape for `summarize`.
|
||||
fn build_brief_messages(
|
||||
meeting_title: &str,
|
||||
participants: &[String],
|
||||
target_repo: Option<&str>,
|
||||
transcript: &str,
|
||||
) -> (String, String) {
|
||||
let mut user = format!("Meeting: {meeting_title}\n");
|
||||
if !participants.is_empty() {
|
||||
user.push_str(&format!("Participants: {}\n", participants.join(", ")));
|
||||
}
|
||||
if let Some(repo) = target_repo {
|
||||
user.push_str(&format!("Target repo: {repo}\n"));
|
||||
}
|
||||
user.push_str("\nTranscript:\n");
|
||||
user.push_str(transcript);
|
||||
(BRIEF_INSTRUCTIONS.to_string(), user)
|
||||
}
|
||||
|
||||
/// Splits a "## Title / ## Problem / ## Desired Outcome / ## Acceptance
|
||||
/// Criteria" Markdown reply (see `BRIEF_INSTRUCTIONS`) into `BriefFields` —
|
||||
/// mirrors `llm::parse_summary`. A missing/malformed section never panics:
|
||||
/// `title`/`problem`/`desired_outcome` fall back to `""` (title falls back
|
||||
/// further, to `meeting_title`, since a brief with no title at all is
|
||||
/// unusable), and `acceptance_criteria` falls back to `[]`.
|
||||
pub fn parse_brief(md: &str, meeting_title: &str) -> BriefFields {
|
||||
let mut title = String::new();
|
||||
let mut problem = String::new();
|
||||
let mut desired_outcome = String::new();
|
||||
let mut acceptance_criteria = Vec::new();
|
||||
let mut section = -1i8; // 0 title, 1 problem, 2 desired outcome, 3 acceptance criteria, -1 other/unknown
|
||||
|
||||
for line in md.lines() {
|
||||
let lower = line.trim().to_ascii_lowercase();
|
||||
if lower.starts_with("## title") {
|
||||
section = 0;
|
||||
continue;
|
||||
}
|
||||
if lower.starts_with("## problem") {
|
||||
section = 1;
|
||||
continue;
|
||||
}
|
||||
if lower.starts_with("## desired outcome") {
|
||||
section = 2;
|
||||
continue;
|
||||
}
|
||||
if lower.starts_with("## acceptance criteria") {
|
||||
section = 3;
|
||||
continue;
|
||||
}
|
||||
if line.trim_start().starts_with('#') {
|
||||
section = -1;
|
||||
continue;
|
||||
}
|
||||
match section {
|
||||
0 => {
|
||||
let text = line.trim();
|
||||
if !text.is_empty() {
|
||||
if !title.is_empty() {
|
||||
title.push(' ');
|
||||
}
|
||||
title.push_str(text);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
problem.push_str(line);
|
||||
problem.push('\n');
|
||||
}
|
||||
2 => {
|
||||
desired_outcome.push_str(line);
|
||||
desired_outcome.push('\n');
|
||||
}
|
||||
3 => {
|
||||
if let Some(item) = bullet_text(line) {
|
||||
acceptance_criteria.push(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let title = title.trim().to_string();
|
||||
BriefFields {
|
||||
title: if title.is_empty() {
|
||||
meeting_title.to_string()
|
||||
} else {
|
||||
title
|
||||
},
|
||||
problem: problem.trim().to_string(),
|
||||
desired_outcome: desired_outcome.trim().to_string(),
|
||||
acceptance_criteria,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercased alphanumeric words of length >= 3 — short enough to skip
|
||||
/// common stopwords ("the", "to", "we") without a stopword list, long enough
|
||||
/// to still catch meaningful terms.
|
||||
fn keywords(text: &str) -> HashSet<String> {
|
||||
text.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 3)
|
||||
.map(|w| w.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn display_name(label: &str, speakers: &[SpeakerInfo]) -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
}
|
||||
|
||||
const MIN_EXCERPT_LEN: usize = 40;
|
||||
const MAX_EXCERPTS: usize = 5;
|
||||
const FALLBACK_EXCERPTS: usize = 3;
|
||||
|
||||
/// Selects grounding excerpts for a brief (the M1 grounding invariant: every
|
||||
/// returned `text` is copied verbatim from a transcript segment — never
|
||||
/// model paraphrase). Scores each substantive segment (>= ~40 chars) by
|
||||
/// keyword overlap with `problem` + `acceptance_criteria`, taking the top
|
||||
/// <= 5; falls back to the first 3 substantive segments if nothing scores
|
||||
/// (e.g. a terse reply with too few keywords, or a transcript that just
|
||||
/// doesn't share vocabulary with the drafted brief).
|
||||
// ponytail: keyword-overlap select; upgrade to embedding similarity if excerpts feel off
|
||||
fn context_excerpts(
|
||||
fields: &BriefFields,
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
) -> Vec<ContextExcerpt> {
|
||||
let mut query = keywords(&fields.problem);
|
||||
query.extend(keywords(&fields.acceptance_criteria.join(" ")));
|
||||
|
||||
let substantive: Vec<&TranscriptSegment> = segments
|
||||
.iter()
|
||||
.filter(|s| s.text.trim().len() >= MIN_EXCERPT_LEN)
|
||||
.collect();
|
||||
|
||||
if !query.is_empty() {
|
||||
let mut scored: Vec<(usize, &TranscriptSegment)> = substantive
|
||||
.iter()
|
||||
.map(|&s| (keywords(&s.text).intersection(&query).count(), s))
|
||||
.filter(|(overlap, _)| *overlap > 0)
|
||||
.collect();
|
||||
if !scored.is_empty() {
|
||||
// Stable sort keeps original (chronological) order among ties.
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
return scored
|
||||
.into_iter()
|
||||
.take(MAX_EXCERPTS)
|
||||
.map(|(_, s)| ContextExcerpt {
|
||||
speaker: display_name(&s.speaker, speakers),
|
||||
text: s.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
substantive
|
||||
.into_iter()
|
||||
.take(FALLBACK_EXCERPTS)
|
||||
.map(|s| ContextExcerpt {
|
||||
speaker: display_name(&s.speaker, speakers),
|
||||
text: s.text.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Transcript-derived inputs `distill` needs — bundled into one struct so the
|
||||
/// function stays under clippy's argument-count lint rather than taking each
|
||||
/// field positionally.
|
||||
struct MeetingContext<'a> {
|
||||
title: &'a str,
|
||||
participants: &'a [String],
|
||||
transcript_md: &'a str,
|
||||
segments: &'a [TranscriptSegment],
|
||||
speakers: &'a [SpeakerInfo],
|
||||
}
|
||||
|
||||
/// Core distillation: prompt -> LLM round trip -> parse -> ground. Takes
|
||||
/// transcript data directly rather than a `MeetingId`, so it needs no
|
||||
/// `Store` — `LlmFeatureBriefBuilder::build` below is the `Store`-aware
|
||||
/// wrapper that looks the meeting up first.
|
||||
async fn distill(
|
||||
llm: &dyn LlmProvider,
|
||||
meeting_id: &MeetingId,
|
||||
target_repo: Option<&str>,
|
||||
ctx: &MeetingContext<'_>,
|
||||
) -> Result<FeatureBrief, BriefError> {
|
||||
let (system, user) =
|
||||
build_brief_messages(ctx.title, ctx.participants, target_repo, ctx.transcript_md);
|
||||
let reply = llm.complete(&system, &user).await?;
|
||||
let fields = parse_brief(&reply, ctx.title);
|
||||
let context_excerpts = context_excerpts(&fields, ctx.segments, ctx.speakers);
|
||||
Ok(FeatureBrief {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
meeting_id: meeting_id.clone(),
|
||||
title: fields.title,
|
||||
problem: fields.problem,
|
||||
desired_outcome: fields.desired_outcome,
|
||||
acceptance_criteria: fields.acceptance_criteria,
|
||||
target_repo: target_repo.map(str::to_string),
|
||||
context_excerpts,
|
||||
})
|
||||
}
|
||||
|
||||
/// `FeatureBriefBuilder` impl used by `commands::create_feature_brief`:
|
||||
/// loads the meeting via `store`, then distills it with `llm`.
|
||||
pub struct LlmFeatureBriefBuilder {
|
||||
pub store: Arc<dyn Store>,
|
||||
pub llm: Box<dyn LlmProvider>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeatureBriefBuilder for LlmFeatureBriefBuilder {
|
||||
async fn build(
|
||||
&self,
|
||||
meeting_id: &MeetingId,
|
||||
target_repo: Option<&str>,
|
||||
) -> Result<FeatureBrief, BriefError> {
|
||||
let meeting = self.store.get_meeting(meeting_id).await?;
|
||||
let participants: Vec<String> = meeting
|
||||
.speakers
|
||||
.iter()
|
||||
.map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone()))
|
||||
.collect();
|
||||
let ctx = MeetingContext {
|
||||
title: &meeting.title,
|
||||
participants: &participants,
|
||||
transcript_md: &meeting.notes_markdown,
|
||||
segments: &meeting.segments,
|
||||
speakers: &meeting.speakers,
|
||||
};
|
||||
distill(self.llm.as_ref(), meeting_id, target_repo, &ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::{LlmStatus, Prompt, Summary, TokenSink};
|
||||
|
||||
/// No-network stand-in for a real provider (T10.6 test — the golden-
|
||||
/// transcript builder test must never touch a socket). Only `complete`
|
||||
/// is exercised by `distill`; the rest are unused stubs.
|
||||
struct MockLlmProvider {
|
||||
reply: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockLlmProvider {
|
||||
async fn status(&self) -> LlmStatus {
|
||||
LlmStatus {
|
||||
provider: "mock".to_string(),
|
||||
reachable: true,
|
||||
is_local: true,
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
async fn summarize(&self, _prompt: Prompt, _out: TokenSink) -> Result<Summary, LlmError> {
|
||||
unimplemented!("not exercised by the brief-builder test")
|
||||
}
|
||||
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
fn is_local(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
const GOLDEN_REPLY: &str = "## Title\n\
|
||||
Bulk CSV export for the reporting view\n\n\
|
||||
## Problem\n\
|
||||
Customers can't get their filtered report data out for offline analysis.\n\n\
|
||||
## Desired Outcome\n\
|
||||
One-click CSV export of the current filtered report.\n\n\
|
||||
## Acceptance Criteria\n\
|
||||
- Export button on the report toolbar\n\
|
||||
- Respects active filters and column order\n\
|
||||
- Streams large exports without blocking the UI\n";
|
||||
|
||||
fn seg(id: u64, speaker: &str, text: &str) -> TranscriptSegment {
|
||||
TranscriptSegment {
|
||||
id,
|
||||
start_ms: id * 1000,
|
||||
end_ms: id * 1000 + 900,
|
||||
speaker: speaker.to_string(),
|
||||
text: text.to_string(),
|
||||
confidence: Some(0.9),
|
||||
interim: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_segments() -> Vec<TranscriptSegment> {
|
||||
vec![
|
||||
seg(0, "S1", "Let's start with the reporting view."),
|
||||
seg(
|
||||
1,
|
||||
"S2",
|
||||
"We really need to pull this filtered report into our own spreadsheets for offline analysis.",
|
||||
),
|
||||
seg(2, "S1", "Makes sense — what would the export button need to respect?"),
|
||||
seg(
|
||||
3,
|
||||
"S2",
|
||||
"It has to respect the active filters and the column order we've already set up.",
|
||||
),
|
||||
seg(4, "S1", "And it can't block the UI while a large export streams out."),
|
||||
seg(5, "S2", "Right, exactly."),
|
||||
]
|
||||
}
|
||||
|
||||
fn golden_speakers() -> Vec<SpeakerInfo> {
|
||||
vec![
|
||||
SpeakerInfo {
|
||||
label: "S1".to_string(),
|
||||
display_name: Some("Alex".to_string()),
|
||||
participant_id: None,
|
||||
},
|
||||
SpeakerInfo {
|
||||
label: "S2".to_string(),
|
||||
display_name: Some("Customer".to_string()),
|
||||
participant_id: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ---- parse_brief ----
|
||||
|
||||
#[test]
|
||||
fn parse_brief_splits_the_four_requested_sections() {
|
||||
let fields = parse_brief(GOLDEN_REPLY, "fallback title");
|
||||
assert_eq!(fields.title, "Bulk CSV export for the reporting view");
|
||||
assert_eq!(
|
||||
fields.problem,
|
||||
"Customers can't get their filtered report data out for offline analysis."
|
||||
);
|
||||
assert_eq!(
|
||||
fields.desired_outcome,
|
||||
"One-click CSV export of the current filtered report."
|
||||
);
|
||||
assert_eq!(
|
||||
fields.acceptance_criteria,
|
||||
vec![
|
||||
"Export button on the report toolbar",
|
||||
"Respects active filters and column order",
|
||||
"Streams large exports without blocking the UI",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_brief_falls_back_to_the_meeting_title_when_no_title_section() {
|
||||
let text = "## Problem\nSomething broke.\n";
|
||||
let fields = parse_brief(text, "Sprint planning");
|
||||
assert_eq!(fields.title, "Sprint planning");
|
||||
assert_eq!(fields.problem, "Something broke.");
|
||||
assert_eq!(fields.desired_outcome, "");
|
||||
assert!(fields.acceptance_criteria.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_brief_of_empty_or_malformed_input_is_empty_and_does_not_panic() {
|
||||
let fields = parse_brief("", "Meeting title");
|
||||
assert_eq!(fields.title, "Meeting title");
|
||||
assert_eq!(fields.problem, "");
|
||||
assert_eq!(fields.desired_outcome, "");
|
||||
assert!(fields.acceptance_criteria.is_empty());
|
||||
|
||||
// No recognized headings at all — everything before the first `#`
|
||||
// (there is none) is just unattributed prose, so nothing is captured.
|
||||
let fields = parse_brief("just some prose with no headings", "Meeting title");
|
||||
assert_eq!(fields.title, "Meeting title");
|
||||
assert!(fields.problem.is_empty());
|
||||
assert!(fields.acceptance_criteria.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_brief_ignores_unrecognized_extra_sections() {
|
||||
let text = "## Title\nFix the thing\n\n## Notes\nirrelevant chatter\n\n\
|
||||
## Acceptance Criteria\n- It works\n";
|
||||
let fields = parse_brief(text, "fallback");
|
||||
assert_eq!(fields.title, "Fix the thing");
|
||||
assert_eq!(fields.acceptance_criteria, vec!["It works"]);
|
||||
}
|
||||
|
||||
// ---- context_excerpts / grounding invariant ----
|
||||
|
||||
#[test]
|
||||
fn context_excerpts_are_verbatim_substrings_of_a_transcript_segment() {
|
||||
let fields = parse_brief(GOLDEN_REPLY, "fallback");
|
||||
let segments = golden_segments();
|
||||
let speakers = golden_speakers();
|
||||
let excerpts = context_excerpts(&fields, &segments, &speakers);
|
||||
assert!(!excerpts.is_empty());
|
||||
for excerpt in &excerpts {
|
||||
assert!(
|
||||
segments.iter().any(|s| s.text.contains(&excerpt.text)),
|
||||
"excerpt {:?} is not a verbatim substring of any transcript segment",
|
||||
excerpt.text
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_excerpts_resolve_speaker_display_names() {
|
||||
let fields = parse_brief(GOLDEN_REPLY, "fallback");
|
||||
let segments = golden_segments();
|
||||
let speakers = golden_speakers();
|
||||
let excerpts = context_excerpts(&fields, &segments, &speakers);
|
||||
assert!(excerpts.iter().any(|e| e.speaker == "Customer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_excerpts_falls_back_to_first_substantive_segments_with_no_keyword_overlap() {
|
||||
let fields = BriefFields {
|
||||
title: "t".to_string(),
|
||||
problem: "zzzzz qqqqq".to_string(), // shares no vocabulary with the transcript
|
||||
desired_outcome: String::new(),
|
||||
acceptance_criteria: vec!["wwwww".to_string()],
|
||||
};
|
||||
let segments = golden_segments();
|
||||
let excerpts = context_excerpts(&fields, &segments, &golden_speakers());
|
||||
assert_eq!(excerpts.len(), FALLBACK_EXCERPTS);
|
||||
for excerpt in &excerpts {
|
||||
assert!(segments.iter().any(|s| s.text.contains(&excerpt.text)));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- distill (the FeatureBriefBuilder golden-transcript test) ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn distill_over_a_golden_transcript_yields_grounded_non_empty_criteria() {
|
||||
let llm = MockLlmProvider {
|
||||
reply: GOLDEN_REPLY.to_string(),
|
||||
};
|
||||
let segments = golden_segments();
|
||||
let speakers = golden_speakers();
|
||||
let participants: Vec<String> = speakers
|
||||
.iter()
|
||||
.map(|s| s.display_name.clone().unwrap())
|
||||
.collect();
|
||||
let transcript_md = segments
|
||||
.iter()
|
||||
.map(|s| format!("**{}:** {}", s.speaker, s.text))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let ctx = MeetingContext {
|
||||
title: "Reporting sync",
|
||||
participants: &participants,
|
||||
transcript_md: &transcript_md,
|
||||
segments: &segments,
|
||||
speakers: &speakers,
|
||||
};
|
||||
let brief = distill(&llm, &"m1".to_string(), Some("acme/reporting-web"), &ctx)
|
||||
.await
|
||||
.expect("distill should succeed against the mock provider");
|
||||
|
||||
assert_eq!(brief.meeting_id, "m1");
|
||||
assert_eq!(brief.title, "Bulk CSV export for the reporting view");
|
||||
assert!(!brief.acceptance_criteria.is_empty());
|
||||
assert_eq!(brief.target_repo.as_deref(), Some("acme/reporting-web"));
|
||||
assert!(!brief.context_excerpts.is_empty());
|
||||
for excerpt in &brief.context_excerpts {
|
||||
assert!(
|
||||
segments.iter().any(|s| s.text.contains(&excerpt.text)),
|
||||
"grounding invariant violated: {:?}",
|
||||
excerpt.text
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn distill_propagates_an_llm_error_without_panicking() {
|
||||
struct FailingProvider;
|
||||
#[async_trait]
|
||||
impl LlmProvider for FailingProvider {
|
||||
async fn status(&self) -> LlmStatus {
|
||||
LlmStatus {
|
||||
provider: "mock".to_string(),
|
||||
reachable: false,
|
||||
is_local: true,
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
async fn summarize(
|
||||
&self,
|
||||
_prompt: Prompt,
|
||||
_out: TokenSink,
|
||||
) -> Result<Summary, LlmError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn complete(&self, _system: &str, _user: &str) -> Result<String, LlmError> {
|
||||
Err(LlmError::Unreachable("connection refused".to_string()))
|
||||
}
|
||||
fn is_local(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = MeetingContext {
|
||||
title: "Meeting",
|
||||
participants: &[],
|
||||
transcript_md: "transcript",
|
||||
segments: &[],
|
||||
speakers: &[],
|
||||
};
|
||||
let result = distill(&FailingProvider, &"m1".to_string(), None, &ctx).await;
|
||||
assert!(matches!(result, Err(BriefError::Llm(_))));
|
||||
}
|
||||
}
|
||||
+869
-25
@@ -9,6 +9,8 @@
|
||||
//! writes to it.
|
||||
|
||||
use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent};
|
||||
use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -22,11 +24,18 @@ pub enum CalError {
|
||||
Password,
|
||||
#[error("readpst isn't installed — install libpst and ensure readpst is on PATH")]
|
||||
ToolMissing,
|
||||
#[error("network request failed: {0}")]
|
||||
Network(String),
|
||||
}
|
||||
|
||||
pub struct CalImport {
|
||||
pub path: String,
|
||||
pub password: Option<String>,
|
||||
/// Date-range window (unix seconds) for a source that fetches by range
|
||||
/// (Graph's `calendarView`, M4.4); ignored by file-based sources like
|
||||
/// `PstSource`, which import everything a `.pst` contains.
|
||||
pub from: Option<i64>,
|
||||
pub to: Option<i64>,
|
||||
}
|
||||
|
||||
pub trait CalendarSource: Send + Sync {
|
||||
@@ -53,10 +62,41 @@ impl CalendarSource for PstSource {
|
||||
|
||||
let result = run_readpst(&input.path, &out_dir).and_then(|_| collect_events(&out_dir));
|
||||
let _ = std::fs::remove_dir_all(&out_dir); // best-effort cleanup either way
|
||||
result
|
||||
|
||||
// Bug fix: `from`/`to` used to be silently ignored for PST (only
|
||||
// Graph honored a range) — a long-lived mailbox has no natural
|
||||
// upper bound on history, so "import everything" meant every
|
||||
// recurring series expanded across its full lifetime (up to 500
|
||||
// occurrences each, T4.1's RECURRENCE_MAX_OCCURRENCES) plus every
|
||||
// one-off entry (e.g. a decade of Outlook's auto-generated yearly
|
||||
// holidays) the file has ever held. Filtering here, after parsing,
|
||||
// is the simplest correct place: it doesn't need to change how
|
||||
// readpst is invoked or how recurrence expansion works.
|
||||
result.map(|events| filter_by_range(events, input.from, input.to))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pst")]
|
||||
fn filter_by_range(
|
||||
events: Vec<ImportedEvent>,
|
||||
from: Option<i64>,
|
||||
to: Option<i64>,
|
||||
) -> Vec<ImportedEvent> {
|
||||
if from.is_none() && to.is_none() {
|
||||
return events;
|
||||
}
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| match e.event.starts_at {
|
||||
// An event with no known start time can't be range-tested —
|
||||
// keep it rather than silently drop something the user might
|
||||
// still want (this is rare; most PST appointments have a start).
|
||||
None => true,
|
||||
Some(start) => from.map_or(true, |f| start >= f) && to.map_or(true, |t| start <= t),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "pst")]
|
||||
fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
|
||||
// -S: one file per item. -e: extension matches item type (.ics for
|
||||
@@ -69,19 +109,35 @@ fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
|
||||
// still handled safely (collect_ics_files skips a file that doesn't
|
||||
// read as valid UTF-8 rather than erroring), just silently dropped
|
||||
// instead of correctly decoded. Revisit if that's observed in practice.
|
||||
let output = Command::new("readpst")
|
||||
.args(["-S", "-e", "-t", "a", "-o"])
|
||||
let mut cmd = Command::new("readpst");
|
||||
cmd.args(["-S", "-e", "-t", "a", "-o"])
|
||||
.arg(out_dir)
|
||||
.arg(pst_path)
|
||||
.output()
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => CalError::ToolMissing,
|
||||
_ => CalError::Open(e.to_string()),
|
||||
})?;
|
||||
.arg(pst_path);
|
||||
// Bug fix: readpst.exe is a console-subsystem binary, and WhispAssist is
|
||||
// a GUI app with no console of its own — Windows was popping a brand
|
||||
// new console window for it on every import. CREATE_NO_WINDOW spawns it
|
||||
// fully headless instead; readpst's own stdout/stderr are still
|
||||
// captured normally via `.output()` below.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => CalError::ToolMissing,
|
||||
_ => CalError::Open(e.to_string()),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(CalError::Parse(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
));
|
||||
// readpst writes some errors to stdout rather than stderr; show
|
||||
// whichever stream actually has text, stderr first.
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let message = if stderr.is_empty() {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(CalError::Parse(message));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,6 +170,205 @@ fn collect_ics_files(dir: &Path, out: &mut Vec<ImportedEvent>) -> Result<(), Cal
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Microsoft Graph calendar source (M4.4, T8.9, FR-CAL-6) ----
|
||||
//
|
||||
// Opt-in, explicit-consent (OAuth 2.0 PKCE via `sync::oauth` — same identity
|
||||
// platform and token-set shape as the OneDrive sync target, ADR-0010) and
|
||||
// metadata-only per ADR-0008: subject, organizer, start/end, attendees —
|
||||
// never the event body. Uses Graph's `calendarView` endpoint, which expands
|
||||
// recurring series into concrete occurrences server-side, so unlike
|
||||
// `PstSource` there's no local RRULE expansion to do.
|
||||
#[cfg(feature = "sync")]
|
||||
pub struct GraphSource {
|
||||
pub credential_ref: String,
|
||||
}
|
||||
|
||||
// ponytail: one page (no `@odata.nextLink` follow) — plenty for a personal
|
||||
// calendar's near-term window; add pagination if a real user's date range
|
||||
// ever needs more than this in one import.
|
||||
#[cfg(feature = "sync")]
|
||||
const GRAPH_EVENTS_PAGE_SIZE: u32 = 250;
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
impl GraphSource {
|
||||
/// Graph API base — overridable via `WA_GRAPH_CALENDAR_BASE_URL` so tests
|
||||
/// can point this at a local mock. Kept distinct from sync's
|
||||
/// `WA_GRAPH_BASE_URL` (used by `OneDriveTarget`) so calendar and sync
|
||||
/// tests never race on the same process-global env var.
|
||||
fn graph_base() -> String {
|
||||
std::env::var("WA_GRAPH_CALENDAR_BASE_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "https://graph.microsoft.com/v1.0".to_string())
|
||||
}
|
||||
|
||||
async fn fetch_events(&self, from: i64, to: i64) -> Result<Vec<ImportedEvent>, CalError> {
|
||||
let token = crate::sync::resolve_access_token("graph-calendar", &self.credential_ref)
|
||||
.await
|
||||
.map_err(|e| CalError::Network(e.to_string()))?;
|
||||
let url = format!(
|
||||
"{}/me/calendarView?startDateTime={}&endDateTime={}&$select=id,subject,organizer,start,end,attendees&$top={}",
|
||||
Self::graph_base(),
|
||||
iso_datetime(from),
|
||||
iso_datetime(to),
|
||||
GRAPH_EVENTS_PAGE_SIZE,
|
||||
);
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
// Ask Graph to return every dateTime already normalized to UTC —
|
||||
// avoids needing a timezone database (same tradeoff PST parsing
|
||||
// makes: see `parse_ics_datetime`'s doc comment).
|
||||
.header("Prefer", r#"outlook.timezone="UTC""#)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CalError::Network(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(CalError::Network(format!(
|
||||
"graph calendarView returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
let body: GraphEventsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CalError::Parse(e.to_string()))?;
|
||||
Ok(body
|
||||
.value
|
||||
.into_iter()
|
||||
.map(graph_event_to_imported)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
impl CalendarSource for GraphSource {
|
||||
/// Sync per the trait — bridges to the async Graph call via
|
||||
/// `tauri::async_runtime::block_on`. Callers (the `import_graph_calendar`
|
||||
/// command) run this inside `spawn_blocking`, exactly like `PstSource`'s
|
||||
/// blocking subprocess call.
|
||||
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError> {
|
||||
let now = Utc::now().timestamp();
|
||||
let from = input.from.unwrap_or(now - 30 * 86_400);
|
||||
let to = input.to.unwrap_or(now + 90 * 86_400);
|
||||
tauri::async_runtime::block_on(self.fetch_events(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphEventsResponse {
|
||||
value: Vec<GraphEvent>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphEvent {
|
||||
id: String,
|
||||
subject: Option<String>,
|
||||
organizer: Option<GraphOrganizer>,
|
||||
start: Option<GraphDateTime>,
|
||||
end: Option<GraphDateTime>,
|
||||
attendees: Option<Vec<GraphAttendee>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphOrganizer {
|
||||
#[serde(rename = "emailAddress")]
|
||||
email_address: GraphEmailAddress,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphEmailAddress {
|
||||
name: Option<String>,
|
||||
address: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphDateTime {
|
||||
#[serde(rename = "dateTime")]
|
||||
date_time: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[derive(Deserialize)]
|
||||
struct GraphAttendee {
|
||||
#[serde(rename = "emailAddress")]
|
||||
email_address: GraphEmailAddress,
|
||||
// required|optional|resource, passed through as-is (Graph's own vocabulary
|
||||
// is a superset of the organizer|required|optional convention the rest of
|
||||
// WA uses for attendee role).
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
fn graph_event_to_imported(e: GraphEvent) -> ImportedEvent {
|
||||
let attendees = e
|
||||
.attendees
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|a| {
|
||||
let name = a
|
||||
.email_address
|
||||
.name
|
||||
.or_else(|| a.email_address.address.clone())?;
|
||||
Some(AttendeeInfo {
|
||||
name,
|
||||
email: a.email_address.address,
|
||||
role: a.kind,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let organizer = e
|
||||
.organizer
|
||||
.and_then(|o| o.email_address.name.or(o.email_address.address));
|
||||
ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: "graph".to_string(),
|
||||
subject: e.subject,
|
||||
organizer,
|
||||
starts_at: e.start.and_then(|s| parse_graph_datetime(&s.date_time)),
|
||||
ends_at: e.end.and_then(|s| parse_graph_datetime(&s.date_time)),
|
||||
description: None, // metadata only (FR-CAL-6) — the event body is never fetched
|
||||
raw_uid: Some(e.id),
|
||||
},
|
||||
attendees,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a unix timestamp as the `YYYY-MM-DDTHH:MM:SS` Graph's
|
||||
/// `calendarView` query params expect.
|
||||
#[cfg(feature = "sync")]
|
||||
fn iso_datetime(unix_secs: i64) -> String {
|
||||
Utc.timestamp_opt(unix_secs, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parses a Graph `dateTime` value (`"2026-07-01T09:00:00.0000000"`, already
|
||||
/// normalized to UTC by the `Prefer: outlook.timezone="UTC"` request header)
|
||||
/// to a unix epoch. Fixed-width slicing, not a general datetime parser — the
|
||||
/// fractional-second suffix (if any) is simply ignored.
|
||||
#[cfg(feature = "sync")]
|
||||
fn parse_graph_datetime(value: &str) -> Option<i64> {
|
||||
if value.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let year: i64 = value.get(0..4)?.parse().ok()?;
|
||||
let month: u32 = value.get(5..7)?.parse().ok()?;
|
||||
let day: u32 = value.get(8..10)?.parse().ok()?;
|
||||
let hour: u32 = value.get(11..13)?.parse().ok()?;
|
||||
let min: u32 = value.get(14..16)?.parse().ok()?;
|
||||
let sec: u32 = value.get(17..19)?.parse().ok()?;
|
||||
Some(ymd_hms_to_unix(year, month, day, hour, min, sec))
|
||||
}
|
||||
|
||||
// ---- iCalendar (RFC 5545) VEVENT parsing — pure, no I/O ----
|
||||
|
||||
fn unfold_lines(text: &str) -> Vec<String> {
|
||||
@@ -202,6 +457,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
let mut description = None;
|
||||
let mut starts_at = None;
|
||||
let mut ends_at = None;
|
||||
let mut rrule: Option<String> = None;
|
||||
let mut attendees: Vec<AttendeeInfo> = Vec::new();
|
||||
|
||||
for line in &lines {
|
||||
@@ -217,22 +473,60 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
description = None;
|
||||
starts_at = None;
|
||||
ends_at = None;
|
||||
rrule = None;
|
||||
attendees = Vec::new();
|
||||
}
|
||||
"END" if value.eq_ignore_ascii_case("VEVENT") && in_event => {
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.take(),
|
||||
organizer: organizer.take(),
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid: uid.take(),
|
||||
},
|
||||
attendees: std::mem::take(&mut attendees),
|
||||
});
|
||||
let attendees = std::mem::take(&mut attendees);
|
||||
match (rrule.take(), &uid, starts_at) {
|
||||
// A recurring event needs a UID to key its occurrences
|
||||
// for dedup (source, raw_uid) — without one, fall back
|
||||
// to importing just the single stored occurrence below.
|
||||
(Some(rule), Some(base_uid), Some(dtstart)) => {
|
||||
let duration = ends_at.map(|e| e - dtstart).unwrap_or(0);
|
||||
for occ_start in expand_rrule(&rule, dtstart) {
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.clone(),
|
||||
organizer: organizer.clone(),
|
||||
starts_at: Some(occ_start),
|
||||
ends_at: Some(occ_start + duration),
|
||||
description: description.clone(),
|
||||
raw_uid: Some(format!("{base_uid}@{}", ymd_digits(occ_start))),
|
||||
},
|
||||
attendees: attendees.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Bug fix: a VEVENT with no UID line used to import
|
||||
// with `raw_uid: None`, which the dedup unique index
|
||||
// (source, raw_uid) explicitly exempts (`WHERE
|
||||
// raw_uid IS NOT NULL`) — so every re-import created
|
||||
// a brand-new duplicate row for it forever. Fall back
|
||||
// to a deterministic hash of the event's own content
|
||||
// so re-imports of the same source still resolve to
|
||||
// the same key and upsert instead of duplicating.
|
||||
let raw_uid = uid.take().or_else(|| {
|
||||
Some(content_uid(&summary, &organizer, starts_at, ends_at))
|
||||
});
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.take(),
|
||||
organizer: organizer.take(),
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid,
|
||||
},
|
||||
attendees,
|
||||
});
|
||||
}
|
||||
}
|
||||
in_event = false;
|
||||
}
|
||||
"UID" if in_event => uid = Some(value.to_string()),
|
||||
@@ -240,6 +534,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
"DESCRIPTION" if in_event => description = Some(unescape_text(value)),
|
||||
"DTSTART" if in_event => starts_at = parse_ics_datetime(value),
|
||||
"DTEND" if in_event => ends_at = parse_ics_datetime(value),
|
||||
"RRULE" if in_event => rrule = Some(value.to_string()),
|
||||
"ORGANIZER" if in_event => {
|
||||
let (name, email) = cal_address(params, value);
|
||||
organizer = name.or(email);
|
||||
@@ -261,6 +556,291 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
events
|
||||
}
|
||||
|
||||
// ---- RRULE (RFC 5545 recurrence) expansion — pure, no I/O ----
|
||||
|
||||
// ponytail: caps for RRULEs with no COUNT/UNTIL (only YEARLY holidays do this
|
||||
// in practice) and a hard ceiling regardless — extend both if a real series
|
||||
// needs more instances than this.
|
||||
const RECURRENCE_HORIZON_YEARS: i64 = 10;
|
||||
const RECURRENCE_MAX_OCCURRENCES: usize = 500;
|
||||
|
||||
enum Freq {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
struct Rrule {
|
||||
freq: Freq,
|
||||
interval: i64,
|
||||
count: Option<usize>,
|
||||
until: Option<i64>,
|
||||
byday: Vec<u32>, // weekday indices, 0=SU..6=SA
|
||||
bymonthday: Vec<u32>, // 1..31
|
||||
bymonth: Vec<u32>, // 1..12
|
||||
}
|
||||
|
||||
fn weekday_code_to_index(code: &str) -> Option<u32> {
|
||||
// Strips a leading ordinal like "2MO" ("2nd Monday") — not seen in this
|
||||
// codebase's real-world data (only plain weekday codes are), but the
|
||||
// weekday is all this parser uses either way.
|
||||
let letters: String = code.chars().filter(|c| c.is_ascii_alphabetic()).collect();
|
||||
match letters.to_ascii_uppercase().as_str() {
|
||||
"SU" => Some(0),
|
||||
"MO" => Some(1),
|
||||
"TU" => Some(2),
|
||||
"WE" => Some(3),
|
||||
"TH" => Some(4),
|
||||
"FR" => Some(5),
|
||||
"SA" => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `FREQ=WEEKLY;COUNT=26;BYDAY=MO`-style RRULE values. Supports
|
||||
/// DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/
|
||||
/// BYMONTH — every combination confirmed present in a real 7.2GB mailbox
|
||||
/// (ADR-0008). No BYSETPOS, no per-occurrence exceptions (RECURRENCE-ID).
|
||||
fn parse_rrule(rule: &str) -> Option<Rrule> {
|
||||
let mut freq = None;
|
||||
let mut interval = 1i64;
|
||||
let mut count = None;
|
||||
let mut until = None;
|
||||
let mut byday = Vec::new();
|
||||
let mut bymonthday = Vec::new();
|
||||
let mut bymonth = Vec::new();
|
||||
let mut current_list_key = String::new();
|
||||
for part in rule.split(';') {
|
||||
// readpst joins a multi-value BYDAY with `;` instead of RFC 5545's
|
||||
// `,` (e.g. `BYDAY=MO;TU;WE;TH;FR`), so a continuation token has no
|
||||
// `=` at all — attribute it to whichever list key came before it.
|
||||
let (key, v) = match part.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
current_list_key = k.to_ascii_uppercase();
|
||||
(current_list_key.as_str(), v)
|
||||
}
|
||||
None => (current_list_key.as_str(), part),
|
||||
};
|
||||
match key {
|
||||
"FREQ" => {
|
||||
freq = match v.to_ascii_uppercase().as_str() {
|
||||
"DAILY" => Some(Freq::Daily),
|
||||
"WEEKLY" => Some(Freq::Weekly),
|
||||
"MONTHLY" => Some(Freq::Monthly),
|
||||
"YEARLY" => Some(Freq::Yearly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"INTERVAL" => interval = v.parse().unwrap_or(1).max(1),
|
||||
"COUNT" => count = v.parse().ok(),
|
||||
"UNTIL" => until = parse_ics_datetime(v),
|
||||
"BYDAY" => byday.extend(v.split(',').filter_map(weekday_code_to_index)),
|
||||
"BYMONTHDAY" => bymonthday.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
|
||||
"BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Rrule {
|
||||
freq: freq?,
|
||||
interval,
|
||||
count,
|
||||
until,
|
||||
byday,
|
||||
bymonthday,
|
||||
bymonth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a local wall-clock datetime to a UTC unix timestamp, applying
|
||||
/// whatever DST rule the OS has for that specific calendar date — this is
|
||||
/// what keeps a recurring meeting at the same local time across a DST
|
||||
/// transition instead of drifting by an hour. A skipped (spring-forward gap)
|
||||
/// or ambiguous (fall-back overlap) local time resolves to the OS's earliest
|
||||
/// matching instant rather than failing outright.
|
||||
fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 {
|
||||
match Local.from_local_datetime(&dt) {
|
||||
chrono::LocalResult::Single(ldt) | chrono::LocalResult::Ambiguous(ldt, _) => {
|
||||
ldt.with_timezone(&Utc).timestamp()
|
||||
}
|
||||
chrono::LocalResult::None => dt.and_utc().timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic stand-in identity for a VEVENT that has no `UID` of its own
|
||||
/// (see the `_` arm of `parse_vevents` above), so re-running the same import
|
||||
/// resolves to the same `raw_uid` and upserts in place rather than mints a
|
||||
/// fresh duplicate row every time. Format matches migration
|
||||
/// `0008_calendar_dedup_cleanup.sql`'s SQL-side backfill exactly, so a
|
||||
/// re-import after that cleanup migration converges onto the same row it
|
||||
/// already collapsed duplicates into rather than minting one more.
|
||||
pub(crate) fn content_uid(
|
||||
subject: &Option<String>,
|
||||
organizer: &Option<String>,
|
||||
starts_at: Option<i64>,
|
||||
ends_at: Option<i64>,
|
||||
) -> String {
|
||||
format!(
|
||||
"content:{}|{}|{}|{}",
|
||||
subject.as_deref().unwrap_or(""),
|
||||
organizer.as_deref().unwrap_or(""),
|
||||
starts_at.unwrap_or(0),
|
||||
ends_at.unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bug fix: this used to convert to the machine's *local* timezone before
|
||||
/// formatting, so an occurrence's date (and therefore its dedup key, see
|
||||
/// `raw_uid` below) could come out differently on two imports run either
|
||||
/// side of a DST transition or a timezone change — silently producing a
|
||||
/// second row for the same occurrence on re-import. UTC is stable no matter
|
||||
/// when/where the import runs.
|
||||
fn ymd_digits(unix_secs: i64) -> String {
|
||||
match Utc.timestamp_opt(unix_secs, 0).single() {
|
||||
Some(dt) => format!("{:04}{:02}{:02}", dt.year(), dt.month(), dt.day()),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands an RRULE into occurrence start timestamps (unix seconds). Each
|
||||
/// occurrence keeps `dtstart`'s *local* wall-clock time-of-day (assuming the
|
||||
/// meeting's timezone matches this machine's — reasonable for a single-user
|
||||
/// tool reading its own Outlook data), re-resolving the UTC offset per
|
||||
/// occurrence so a series spanning a DST transition doesn't drift by an hour.
|
||||
fn expand_rrule(rule: &str, dtstart: i64) -> Vec<i64> {
|
||||
let Some(r) = parse_rrule(rule) else {
|
||||
return vec![dtstart];
|
||||
};
|
||||
let Some(dtstart_utc) = Utc.timestamp_opt(dtstart, 0).single() else {
|
||||
return vec![dtstart];
|
||||
};
|
||||
let local_start = dtstart_utc.with_timezone(&Local).naive_local();
|
||||
let (hour, min, sec) = (
|
||||
local_start.hour(),
|
||||
local_start.minute(),
|
||||
local_start.second(),
|
||||
);
|
||||
let start_date = local_start.date();
|
||||
|
||||
let indefinite = r.count.is_none() && r.until.is_none();
|
||||
let effective_until = if indefinite {
|
||||
dtstart + RECURRENCE_HORIZON_YEARS * 365 * 86_400
|
||||
} else {
|
||||
r.until.unwrap_or(i64::MAX)
|
||||
};
|
||||
let count_cap = r
|
||||
.count
|
||||
.unwrap_or(RECURRENCE_MAX_OCCURRENCES)
|
||||
.min(RECURRENCE_MAX_OCCURRENCES);
|
||||
|
||||
let at = |date: NaiveDate| date.and_hms_opt(hour, min, sec).map(local_to_utc_secs);
|
||||
|
||||
let mut occurrences = Vec::new();
|
||||
match r.freq {
|
||||
// Outlook emits "every weekday" as either FREQ, always with BYDAY —
|
||||
// both iterate calendar weeks and keep the requested weekdays.
|
||||
Freq::Weekly | Freq::Daily if !r.byday.is_empty() => {
|
||||
let step_weeks = if matches!(r.freq, Freq::Weekly) {
|
||||
r.interval
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let mut week_start =
|
||||
start_date - Duration::days(start_date.weekday().num_days_from_sunday() as i64);
|
||||
'weeks: loop {
|
||||
for &wd in &r.byday {
|
||||
let date = week_start + Duration::days(wd as i64);
|
||||
if date < start_date {
|
||||
continue;
|
||||
}
|
||||
let Some(ts) = at(date) else { continue };
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break 'weeks;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
}
|
||||
week_start += Duration::weeks(step_weeks);
|
||||
}
|
||||
}
|
||||
Freq::Daily => {
|
||||
let mut date = start_date;
|
||||
while let Some(ts) = at(date) {
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
date += Duration::days(r.interval);
|
||||
}
|
||||
}
|
||||
Freq::Weekly => {
|
||||
let mut date = start_date;
|
||||
while let Some(ts) = at(date) {
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
date += Duration::weeks(r.interval);
|
||||
}
|
||||
}
|
||||
Freq::Monthly => {
|
||||
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
|
||||
let mut idx: i64 = 0;
|
||||
loop {
|
||||
let total = start_date.month0() as i64 + idx * r.interval;
|
||||
let year = start_date.year() + total.div_euclid(12) as i32;
|
||||
let month = (total.rem_euclid(12) + 1) as u32;
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
|
||||
if date >= start_date {
|
||||
if let Some(ts) = at(date) {
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
idx += 1;
|
||||
if idx as usize > RECURRENCE_MAX_OCCURRENCES * 2 {
|
||||
break; // safety valve against a pathological rule
|
||||
}
|
||||
}
|
||||
}
|
||||
Freq::Yearly => {
|
||||
let months = if r.bymonth.is_empty() {
|
||||
vec![start_date.month()]
|
||||
} else {
|
||||
r.bymonth.clone()
|
||||
};
|
||||
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
|
||||
let mut year = start_date.year();
|
||||
loop {
|
||||
for &month in &months {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
|
||||
if date >= start_date {
|
||||
if let Some(ts) = at(date) {
|
||||
if ts <= effective_until && occurrences.len() < count_cap {
|
||||
occurrences.push(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
year += r.interval as i32;
|
||||
if occurrences.len() >= count_cap
|
||||
|| year > start_date.year() + (RECURRENCE_HORIZON_YEARS * 2) as i32
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if occurrences.is_empty() {
|
||||
vec![dtstart]
|
||||
} else {
|
||||
occurrences
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or
|
||||
/// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating
|
||||
/// (no `Z`, no `TZID`) values are treated as UTC — full IANA timezone
|
||||
@@ -304,6 +884,70 @@ fn ymd_hms_to_unix(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u3
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[test]
|
||||
fn parse_graph_datetime_ignores_fractional_seconds() {
|
||||
assert_eq!(
|
||||
parse_graph_datetime("2026-07-01T09:00:00.0000000"),
|
||||
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_graph_datetime("2026-07-01T09:00:00"),
|
||||
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
|
||||
);
|
||||
assert_eq!(parse_graph_datetime("not-a-date"), None);
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[test]
|
||||
fn iso_datetime_formats_for_graph_query_params() {
|
||||
assert_eq!(
|
||||
iso_datetime(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0)),
|
||||
"2026-07-01T09:00:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "sync")]
|
||||
#[test]
|
||||
fn graph_event_to_imported_extracts_metadata_only_no_body() {
|
||||
let event = GraphEvent {
|
||||
id: "AAMk...".to_string(),
|
||||
subject: Some("Sprint planning".to_string()),
|
||||
organizer: Some(GraphOrganizer {
|
||||
email_address: GraphEmailAddress {
|
||||
name: Some("Jordan Lee".to_string()),
|
||||
address: Some("jordan@example.com".to_string()),
|
||||
},
|
||||
}),
|
||||
start: Some(GraphDateTime {
|
||||
date_time: "2026-07-01T09:00:00.0000000".to_string(),
|
||||
}),
|
||||
end: Some(GraphDateTime {
|
||||
date_time: "2026-07-01T10:00:00.0000000".to_string(),
|
||||
}),
|
||||
attendees: Some(vec![GraphAttendee {
|
||||
email_address: GraphEmailAddress {
|
||||
name: Some("Alex Kim".to_string()),
|
||||
address: Some("alex@example.com".to_string()),
|
||||
},
|
||||
kind: Some("required".to_string()),
|
||||
}]),
|
||||
};
|
||||
let imported = graph_event_to_imported(event);
|
||||
assert_eq!(imported.event.source, "graph");
|
||||
assert_eq!(imported.event.raw_uid.as_deref(), Some("AAMk..."));
|
||||
assert_eq!(imported.event.subject.as_deref(), Some("Sprint planning"));
|
||||
assert_eq!(imported.event.organizer.as_deref(), Some("Jordan Lee"));
|
||||
assert_eq!(imported.event.description, None);
|
||||
assert_eq!(
|
||||
imported.event.starts_at,
|
||||
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
|
||||
);
|
||||
assert_eq!(imported.attendees.len(), 1);
|
||||
assert_eq!(imported.attendees[0].name, "Alex Kim");
|
||||
assert_eq!(imported.attendees[0].role.as_deref(), Some("required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ymd_hms_to_unix_matches_known_epoch_values() {
|
||||
assert_eq!(ymd_hms_to_unix(1970, 1, 1, 0, 0, 0), 0);
|
||||
@@ -399,7 +1043,207 @@ END:VCALENDAR\r\n";
|
||||
let result = PstSource.import(CalImport {
|
||||
path: "Z:\\no\\such\\file.pst".to_string(),
|
||||
password: None,
|
||||
from: None,
|
||||
to: None,
|
||||
});
|
||||
assert!(matches!(result, Err(CalError::Open(_))));
|
||||
}
|
||||
|
||||
// These assert on *local* wall-clock time rather than raw UTC offsets —
|
||||
// that's the entire point of the DST fix (a fixed UTC time-of-day is
|
||||
// exactly the bug: a recurring meeting drifts an hour across a DST
|
||||
// transition). Local-time assertions depend on this machine's configured
|
||||
// timezone, same as the production code they're testing.
|
||||
fn local_hms(ts: i64) -> (u32, u32, u32) {
|
||||
let dt = Utc.timestamp_opt(ts, 0).unwrap().with_timezone(&Local);
|
||||
(dt.hour(), dt.minute(), dt.second())
|
||||
}
|
||||
fn local_date(ts: i64) -> NaiveDate {
|
||||
Utc.timestamp_opt(ts, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&Local)
|
||||
.date_naive()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_weekly_single_byday_matches_the_real_1on1_pattern() {
|
||||
// The exact rule readpst produced for a real "Weekly 1:1" on Mondays.
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 12, 16, 30, 0); // a Monday
|
||||
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=26;BYDAY=MO", dtstart);
|
||||
assert_eq!(occurrences.len(), 26);
|
||||
assert_eq!(occurrences[0], dtstart);
|
||||
let expected_hms = local_hms(dtstart);
|
||||
for pair in occurrences.windows(2) {
|
||||
assert_eq!((local_date(pair[1]) - local_date(pair[0])).num_days(), 7);
|
||||
}
|
||||
for occ in &occurrences {
|
||||
assert_eq!(
|
||||
local_hms(*occ),
|
||||
expected_hms,
|
||||
"local wall-clock time must not drift across a DST transition"
|
||||
);
|
||||
assert_eq!(local_date(*occ).weekday(), chrono::Weekday::Mon);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_weekly_multi_byday_covers_every_weekday_in_order() {
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 12, 9, 0, 0); // Monday
|
||||
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR", dtstart);
|
||||
assert_eq!(occurrences.len(), 10);
|
||||
// Mon..Fri week 1, then Mon..Fri week 2 — a flat +1 day step except
|
||||
// the weekend gap between index 4 (Fri) and 5 (next Mon).
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
(local_date(occurrences[i + 1]) - local_date(occurrences[i])).num_days(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
(local_date(occurrences[5]) - local_date(occurrences[4])).num_days(),
|
||||
3
|
||||
);
|
||||
let expected_hms = local_hms(dtstart);
|
||||
for occ in &occurrences {
|
||||
assert_eq!(local_hms(*occ), expected_hms);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_monthly_bymonthday_steps_calendar_months() {
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 1, 9, 0, 0);
|
||||
let occurrences = expand_rrule("FREQ=MONTHLY;COUNT=7;BYMONTHDAY=1", dtstart);
|
||||
assert_eq!(occurrences.len(), 7);
|
||||
let last = local_date(occurrences[6]);
|
||||
assert_eq!((last.year(), last.month(), last.day()), (2026, 7, 1));
|
||||
let expected_hms = local_hms(dtstart);
|
||||
for occ in &occurrences {
|
||||
assert_eq!(local_hms(*occ), expected_hms);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_yearly_with_no_count_or_until_is_capped_by_the_horizon() {
|
||||
let dtstart = ymd_hms_to_unix(2020, 11, 11, 17, 0, 0); // afternoon UTC, safe midnight margin
|
||||
let occurrences = expand_rrule("FREQ=YEARLY;BYMONTHDAY=11;BYMONTH=11", dtstart);
|
||||
assert!(
|
||||
!occurrences.is_empty() && occurrences.len() <= RECURRENCE_HORIZON_YEARS as usize + 2
|
||||
);
|
||||
for occ in &occurrences {
|
||||
let d = local_date(*occ);
|
||||
assert_eq!((d.month(), d.day()), (11, 11));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vevents_expands_a_recurring_event_into_distinct_occurrences() {
|
||||
let ics = "BEGIN:VEVENT\r\n\
|
||||
UID:series-1\r\n\
|
||||
SUMMARY:Weekly 1:1\r\n\
|
||||
DTSTART:20260112T163000Z\r\n\
|
||||
DTEND:20260112T170000Z\r\n\
|
||||
RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO\r\n\
|
||||
END:VEVENT\r\n";
|
||||
let events = parse_vevents(ics, "pst");
|
||||
assert_eq!(events.len(), 3);
|
||||
let raw_uids: Vec<_> = events.iter().map(|e| e.event.raw_uid.clone()).collect();
|
||||
assert_eq!(
|
||||
raw_uids.len(),
|
||||
raw_uids
|
||||
.iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len()
|
||||
);
|
||||
for e in &events {
|
||||
assert_eq!(e.event.subject.as_deref(), Some("Weekly 1:1"));
|
||||
assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression for the duplicate-import bug: a VEVENT with no `UID` line
|
||||
/// used to get `raw_uid: None`, which the storage layer's unique index
|
||||
/// exempts from dedup entirely -- so re-importing the same source
|
||||
/// duplicated it on every single run. It must now get a stable,
|
||||
/// content-derived `raw_uid` so re-parsing the identical source resolves
|
||||
/// to the same key.
|
||||
#[test]
|
||||
fn parse_vevents_gives_a_uid_less_event_a_stable_content_based_raw_uid() {
|
||||
let ics = "BEGIN:VEVENT\r\n\
|
||||
SUMMARY:No UID here\r\n\
|
||||
DTSTART:20260112T163000Z\r\n\
|
||||
DTEND:20260112T170000Z\r\n\
|
||||
END:VEVENT\r\n";
|
||||
let first = parse_vevents(ics, "pst");
|
||||
let second = parse_vevents(ics, "pst");
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert!(first[0].event.raw_uid.is_some());
|
||||
assert_eq!(first[0].event.raw_uid, second[0].event.raw_uid);
|
||||
}
|
||||
|
||||
/// Distinct UID-less events (different subjects) must not collide onto
|
||||
/// the same content-based key.
|
||||
#[test]
|
||||
fn content_based_raw_uid_differs_for_distinct_uid_less_events() {
|
||||
let ics_a = "BEGIN:VEVENT\r\nSUMMARY:Event A\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
|
||||
let ics_b = "BEGIN:VEVENT\r\nSUMMARY:Event B\r\nDTSTART:20260112T163000Z\r\nDTEND:20260112T170000Z\r\nEND:VEVENT\r\n";
|
||||
let a = parse_vevents(ics_a, "pst");
|
||||
let b = parse_vevents(ics_b, "pst");
|
||||
assert_ne!(a[0].event.raw_uid, b[0].event.raw_uid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ymd_digits_is_timezone_independent() {
|
||||
// A UTC midnight timestamp must format to the same UTC calendar date
|
||||
// regardless of the machine's local timezone (the original bug:
|
||||
// this used to convert to `Local` first, so the same recurring
|
||||
// occurrence could compute a different dedup-key suffix on a machine
|
||||
// in a different timezone, or after a DST transition).
|
||||
let utc_new_year = Utc
|
||||
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp();
|
||||
assert_eq!(ymd_digits(utc_new_year), "20260101");
|
||||
}
|
||||
|
||||
fn dated_event(subject: &str, starts_at: Option<i64>) -> ImportedEvent {
|
||||
ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: "pst".to_string(),
|
||||
subject: Some(subject.to_string()),
|
||||
organizer: None,
|
||||
starts_at,
|
||||
ends_at: starts_at.map(|s| s + 1800),
|
||||
description: None,
|
||||
raw_uid: Some(subject.to_string()),
|
||||
},
|
||||
attendees: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_keeps_everything_when_unbounded() {
|
||||
let events = vec![dated_event("a", Some(0)), dated_event("b", Some(1_000_000))];
|
||||
assert_eq!(filter_by_range(events.clone(), None, None).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_excludes_events_outside_the_window() {
|
||||
let events = vec![
|
||||
dated_event("too old", Some(100)),
|
||||
dated_event("in range", Some(500)),
|
||||
dated_event("too new", Some(900)),
|
||||
];
|
||||
let kept = filter_by_range(events, Some(200), Some(800));
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].event.subject.as_deref(), Some("in range"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_range_keeps_undated_events_rather_than_guessing() {
|
||||
let events = vec![dated_event("no date", None)];
|
||||
let kept = filter_by_range(events, Some(200), Some(800));
|
||||
assert_eq!(kept.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+2085
-117
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ use crate::models::{SpeakerSpan, TranscriptSegment};
|
||||
use std::path::Path;
|
||||
|
||||
pub mod models;
|
||||
pub mod voiceprint;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DiarError {
|
||||
|
||||
@@ -52,6 +52,9 @@ pub fn list() -> Vec<ModelInfo> {
|
||||
// Both models are always "active" once installed — diarization
|
||||
// has no interchangeable-size picker like whisper's (yet).
|
||||
active: true,
|
||||
// Not a whisper model — the language picker (T8.7) never applies
|
||||
// to diarization's segmentation/embedding pair.
|
||||
multilingual: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Voiceprint matching: identifies which diarized speaker cluster is the
|
||||
//! meeting's own microphone, so it can be auto-labeled "You" instead of a
|
||||
//! clustered "S1"/"S2" (bug: the mic speaker wasn't reliably first/labeled).
|
||||
//! Mic and system audio are already summed into one mono stream before
|
||||
//! diarization ever runs, so the only way to tell them apart afterwards is a
|
||||
//! voiceprint: a short mic-only sample, captured live, compared by embedding
|
||||
//! similarity against each cluster's own audio from the finished recording.
|
||||
//! Runs once per meeting, entirely offline via the same sherpa-onnx
|
||||
//! speaker-embedding model diarization already uses (ADR-0005).
|
||||
|
||||
use crate::models::SpeakerSpan;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// At least this much clean audio (mic sample or candidate cluster) before an
|
||||
/// embedding computed from it is trusted at all — a fragment of a word gives
|
||||
/// an unstable embedding that's as likely to mismatch as match.
|
||||
const MIN_VOICEPRINT_SAMPLES: usize = 16_000; // 1s @ 16kHz
|
||||
|
||||
/// Per-candidate audio is capped so one very long-talking speaker doesn't
|
||||
/// blow up embedding compute time; a few seconds is already stable.
|
||||
const MAX_CANDIDATE_SAMPLES: usize = 16_000 * 10;
|
||||
|
||||
/// sherpa's own default "is this a match" similarity threshold
|
||||
/// (`speaker_id::DEFAULT_SIMILARITY_THRESHOLD`) — kept as a local constant so
|
||||
/// this module doesn't need the `diarization` feature just to state its
|
||||
/// policy (used by both the real and no-op builds' doc comments/tests).
|
||||
const SIMILARITY_THRESHOLD: f32 = 0.5;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VoiceprintError {
|
||||
#[error("model load failed: {0}")]
|
||||
Load(String),
|
||||
#[error("embedding failed: {0}")]
|
||||
Embed(String),
|
||||
#[error("failed to read the recording: {0}")]
|
||||
Read(String),
|
||||
}
|
||||
|
||||
/// The label -> display-name map to auto-apply after diarization: whichever
|
||||
/// speaker's audio matches `mic_samples` best -> `"You"`; every other label,
|
||||
/// in first-appearance order, -> `"Speaker 2"`, `"Speaker 3"`, … An empty map
|
||||
/// means "couldn't tell" (too little mic audio, no cluster cleared the
|
||||
/// similarity threshold, embedding model unavailable) — callers leave the
|
||||
/// existing "S1"/"S2" labels alone rather than guess (FR-SPK-5).
|
||||
#[cfg(feature = "diarization")]
|
||||
pub fn match_mic_speaker(
|
||||
embedding_model: &Path,
|
||||
mic_samples: &[f32],
|
||||
wav_path: &Path,
|
||||
spans: &[SpeakerSpan],
|
||||
) -> Result<HashMap<String, String>, VoiceprintError> {
|
||||
if mic_samples.len() < MIN_VOICEPRINT_SAMPLES || spans.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let labels_in_order = first_appearance_order(spans);
|
||||
|
||||
let wav_samples = crate::audio::read_wav_mono_16k(wav_path)
|
||||
.map_err(|e| VoiceprintError::Read(e.to_string()))?;
|
||||
|
||||
let mut extractor =
|
||||
sherpa_rs::speaker_id::EmbeddingExtractor::new(sherpa_rs::speaker_id::ExtractorConfig {
|
||||
model: embedding_model.to_string_lossy().to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.map_err(|e| VoiceprintError::Load(e.to_string()))?;
|
||||
|
||||
let mic_embedding = extractor
|
||||
.compute_speaker_embedding(mic_samples.to_vec(), 16_000)
|
||||
.map_err(|e| VoiceprintError::Embed(e.to_string()))?;
|
||||
|
||||
let mut best: Option<(&str, f32)> = None;
|
||||
for label in &labels_in_order {
|
||||
let candidate_samples = candidate_audio(&wav_samples, spans, label);
|
||||
if candidate_samples.len() < MIN_VOICEPRINT_SAMPLES {
|
||||
continue;
|
||||
}
|
||||
let embedding = extractor
|
||||
.compute_speaker_embedding(candidate_samples, 16_000)
|
||||
.map_err(|e| VoiceprintError::Embed(e.to_string()))?;
|
||||
let score = cosine_similarity(&mic_embedding, &embedding);
|
||||
let is_better = match best {
|
||||
Some((_, best_score)) => score > best_score,
|
||||
None => true,
|
||||
};
|
||||
if is_better {
|
||||
best = Some((label, score));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((mic_label, score)) = best else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
if score < SIMILARITY_THRESHOLD {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
Ok(build_name_map(&labels_in_order, mic_label))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "diarization"))]
|
||||
pub fn match_mic_speaker(
|
||||
_embedding_model: &Path,
|
||||
_mic_samples: &[f32],
|
||||
_wav_path: &Path,
|
||||
_spans: &[SpeakerSpan],
|
||||
) -> Result<HashMap<String, String>, VoiceprintError> {
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
|
||||
/// Distinct speaker labels in first-appearance order — spans come back from
|
||||
/// the diarizer already sorted by start time.
|
||||
fn first_appearance_order(spans: &[SpeakerSpan]) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
spans
|
||||
.iter()
|
||||
.filter(|s| seen.insert(s.speaker.clone()))
|
||||
.map(|s| s.speaker.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenates up to `MAX_CANDIDATE_SAMPLES` of `label`'s audio out of the
|
||||
/// full 16kHz-mono recording, using each span's millisecond range.
|
||||
fn candidate_audio(wav_samples: &[f32], spans: &[SpeakerSpan], label: &str) -> Vec<f32> {
|
||||
const SAMPLES_PER_MS: u64 = 16; // 16_000 Hz / 1000
|
||||
let mut out = Vec::new();
|
||||
for span in spans.iter().filter(|s| s.speaker == label) {
|
||||
if out.len() >= MAX_CANDIDATE_SAMPLES {
|
||||
break;
|
||||
}
|
||||
let start = (span.start_ms * SAMPLES_PER_MS) as usize;
|
||||
let end = ((span.end_ms * SAMPLES_PER_MS) as usize).min(wav_samples.len());
|
||||
if start < end {
|
||||
out.extend_from_slice(&wav_samples[start..end]);
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_CANDIDATE_SAMPLES);
|
||||
out
|
||||
}
|
||||
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let norm_a = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm_a == 0.0 || norm_b == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
dot / (norm_a * norm_b)
|
||||
}
|
||||
}
|
||||
|
||||
/// `mic_label` -> "You"; every other label, in first-appearance order ->
|
||||
/// "Speaker 2", "Speaker 3", … (numbering starts at 2 — "You" stands in for
|
||||
/// "Speaker 1" without ever being called that).
|
||||
fn build_name_map(labels_in_order: &[String], mic_label: &str) -> HashMap<String, String> {
|
||||
let mut names = HashMap::new();
|
||||
let mut next_speaker_number = 2;
|
||||
for label in labels_in_order {
|
||||
if label == mic_label {
|
||||
names.insert(label.clone(), "You".to_string());
|
||||
} else {
|
||||
names.insert(label.clone(), format!("Speaker {next_speaker_number}"));
|
||||
next_speaker_number += 1;
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn span(start_ms: u64, end_ms: u64, speaker: &str) -> SpeakerSpan {
|
||||
SpeakerSpan {
|
||||
start_ms,
|
||||
end_ms,
|
||||
speaker: speaker.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_appearance_order_dedupes_in_encounter_order() {
|
||||
let spans = vec![
|
||||
span(0, 1000, "S2"),
|
||||
span(1000, 2000, "S1"),
|
||||
span(2000, 3000, "S2"),
|
||||
];
|
||||
assert_eq!(first_appearance_order(&spans), vec!["S2", "S1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_audio_concatenates_only_that_speakers_spans() {
|
||||
let wav: Vec<f32> = (0..32_000).map(|i| i as f32).collect(); // 2s @16kHz
|
||||
let spans = vec![
|
||||
span(0, 500, "S1"),
|
||||
span(500, 1000, "S2"),
|
||||
span(1000, 1500, "S1"),
|
||||
];
|
||||
let s1 = candidate_audio(&wav, &spans, "S1");
|
||||
// 500ms + 500ms of S1 = 1s = 16_000 samples, taken from [0,8000) and [16000,24000).
|
||||
assert_eq!(s1.len(), 16_000);
|
||||
assert_eq!(s1[0], 0.0);
|
||||
assert_eq!(s1[8000], 16_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_audio_caps_at_the_maximum() {
|
||||
let wav: Vec<f32> = vec![0.0; MAX_CANDIDATE_SAMPLES + 10_000];
|
||||
let spans = vec![span(0, (MAX_CANDIDATE_SAMPLES as u64 + 10_000) / 16, "S1")];
|
||||
assert_eq!(
|
||||
candidate_audio(&wav, &spans, "S1").len(),
|
||||
MAX_CANDIDATE_SAMPLES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_identical_vectors_is_one() {
|
||||
let v = [1.0, 2.0, 3.0];
|
||||
assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_of_opposite_vectors_is_negative_one() {
|
||||
let a = [1.0, 0.0];
|
||||
let b = [-1.0, 0.0];
|
||||
assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_similarity_handles_a_zero_vector_without_dividing_by_zero() {
|
||||
let a = [0.0, 0.0];
|
||||
let b = [1.0, 1.0];
|
||||
assert_eq!(cosine_similarity(&a, &b), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_name_map_labels_the_mic_you_and_numbers_the_rest_from_two() {
|
||||
let labels = vec!["S2".to_string(), "S1".to_string(), "S3".to_string()];
|
||||
let names = build_name_map(&labels, "S1");
|
||||
assert_eq!(names.get("S1"), Some(&"You".to_string()));
|
||||
assert_eq!(names.get("S2"), Some(&"Speaker 2".to_string()));
|
||||
assert_eq!(names.get("S3"), Some(&"Speaker 3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_mic_speaker_returns_empty_when_mic_sample_is_too_short() {
|
||||
let spans = vec![span(0, 1000, "S1")];
|
||||
let names = match_mic_speaker(
|
||||
Path::new("model.onnx"),
|
||||
&[0.0; 100],
|
||||
Path::new("audio.wav"),
|
||||
&spans,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(names.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod audio;
|
||||
pub mod briefs;
|
||||
pub mod calendar;
|
||||
pub mod commands;
|
||||
pub mod diarization;
|
||||
@@ -13,6 +14,7 @@ pub mod error;
|
||||
pub mod hardware;
|
||||
pub mod llm;
|
||||
pub mod mcp;
|
||||
pub mod media;
|
||||
pub mod models;
|
||||
pub mod notes;
|
||||
pub mod paths;
|
||||
@@ -44,6 +46,10 @@ pub struct AppState {
|
||||
pub struct RecordingSession {
|
||||
pub meeting_id: models::MeetingId,
|
||||
pub capture: audio::CaptureHandle,
|
||||
/// The user's microphone capture (FR-CAP-7), mixed into the transcript
|
||||
/// stream. `None` when the mic is disabled in Settings or failed to open —
|
||||
/// the meeting proceeds on loopback alone either way.
|
||||
pub mic_capture: Option<audio::CaptureHandle>,
|
||||
/// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting.
|
||||
pub retention: bool,
|
||||
pub wav_path: PathBuf,
|
||||
@@ -58,6 +64,13 @@ pub struct RecordingSession {
|
||||
/// can pick a different model than `Settings.whisper_model`).
|
||||
pub active_backend: Arc<StdMutex<models::BackendId>>,
|
||||
pub model_id: String,
|
||||
/// Resolved transcription language (T8.7, FR-TRX-4): `None` = auto.
|
||||
/// Set by the transcription worker once the engine loads (to the
|
||||
/// request resolved against model capability, e.g. forced "en" for an
|
||||
/// English-only model) and updated after every decode to whatever was
|
||||
/// actually used/detected — `stop_recording` reads the final value to
|
||||
/// persist on the meeting record.
|
||||
pub language: Arc<StdMutex<Option<String>>>,
|
||||
/// `None` when diarization models aren't installed yet (T4.7) — live
|
||||
/// provisional turns and the final post-stop pass are both skipped, same
|
||||
/// graceful-degradation treatment as a missing hardware backend (T4.3).
|
||||
@@ -66,6 +79,19 @@ pub struct RecordingSession {
|
||||
/// (T4.4, FR-SPK-2). Never rewritten onto segments (FR-SPK-5); resolved
|
||||
/// at render/finalize time instead.
|
||||
pub speaker_names: Arc<StdMutex<std::collections::HashMap<String, String>>>,
|
||||
/// A few seconds of raw mic-only audio, captured once early in the
|
||||
/// recording — used at `stop_recording` to voiceprint-match the mic
|
||||
/// against the diarized speaker clusters so the mic speaker can be
|
||||
/// auto-labeled "You" instead of a clustered "S1"/"S2". `None` when the
|
||||
/// mic is disabled (same conditions as `mic_capture`).
|
||||
pub mic_voice_sample: Option<Arc<audio::VoiceSample>>,
|
||||
/// Live notes redesign: raw user-authored notes accumulated *during* the
|
||||
/// recording (freeform text + per-moment annotations) — see
|
||||
/// `models::ManualNotes`. Mutated by `update_live_notes`/`set_segment_note`
|
||||
/// and write-through persisted to `manual_notes.json` on every edit (crash
|
||||
/// safety, same spirit as T2.8 recovery); folded into the final `notes.md`
|
||||
/// at `stop_recording` via `notes::MarkdownNotes::merge`.
|
||||
pub manual_notes: Arc<StdMutex<models::ManualNotes>>,
|
||||
}
|
||||
|
||||
/// Wraps the tray icon so it can be looked up from commands to update its
|
||||
@@ -84,6 +110,11 @@ pub fn run() {
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// In-memory streaming of recordings for the player (FR-REC-5): decrypts
|
||||
// on the fly so no plaintext audio is ever written to disk.
|
||||
.register_uri_scheme_protocol("waaudio", |_ctx, request| {
|
||||
commands::serve_recording(&request)
|
||||
})
|
||||
.manage(AppState {
|
||||
store,
|
||||
session: Mutex::new(None),
|
||||
@@ -123,6 +154,10 @@ pub fn run() {
|
||||
// Startup recovery + retention + reminder-reconcile pass (FR-REL-1,
|
||||
// FR-STORE-2, FR-CAL-5). Spawned so it never blocks the window from
|
||||
// showing (NFR-PERF-4); nothing here repeats on a timer (NFR-RES-1).
|
||||
// Sweep away any plaintext playback temp files left by the previous
|
||||
// file-based player (T8.8) — playback is now in-memory only.
|
||||
commands::cleanup_playback_temp();
|
||||
|
||||
let store = store_for_setup;
|
||||
let startup_app = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -161,28 +196,57 @@ pub fn run() {
|
||||
if commands::load_settings().sync_enabled {
|
||||
commands::pump_sync(&startup_app, store.as_ref()).await;
|
||||
}
|
||||
|
||||
// Re-import the last .pst path if the user opted into auto-sync
|
||||
// (T6.2). One-shot on startup, same as the sync-job resume above
|
||||
// — no idle timer (NFR-RES-1). Re-import is dedup'd by
|
||||
// (source, raw_uid), so this just catches up on new/changed events.
|
||||
let pst_settings = commands::load_settings();
|
||||
if pst_settings.pst_auto_sync {
|
||||
if let Some(path) = pst_settings.pst_last_path {
|
||||
if let Err(e) = commands::import_pst_core(
|
||||
&startup_app,
|
||||
store.as_ref(),
|
||||
path,
|
||||
None,
|
||||
pst_settings.pst_import_range_days,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("startup PST auto-sync failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::start_recording,
|
||||
commands::stop_recording,
|
||||
commands::cancel_recording,
|
||||
commands::recording_playback_path,
|
||||
commands::pause_recording,
|
||||
commands::resume_recording,
|
||||
commands::set_recording_retention,
|
||||
commands::acknowledge_recording_consent,
|
||||
commands::update_live_notes,
|
||||
commands::set_segment_note,
|
||||
commands::resume_transcription,
|
||||
commands::app_info,
|
||||
commands::open_url,
|
||||
commands::hardware_status,
|
||||
commands::list_audio_devices,
|
||||
commands::list_input_devices,
|
||||
commands::set_preferred_backend,
|
||||
commands::list_models,
|
||||
commands::list_whisper_languages,
|
||||
commands::download_npu_package,
|
||||
commands::download_directml_package,
|
||||
commands::list_diarization_models,
|
||||
commands::download_model,
|
||||
commands::remove_model,
|
||||
commands::reprocess_transcript,
|
||||
commands::import_media,
|
||||
commands::list_meetings,
|
||||
commands::search,
|
||||
commands::set_tags,
|
||||
@@ -193,6 +257,7 @@ pub fn run() {
|
||||
commands::update_notes,
|
||||
commands::export_meeting,
|
||||
commands::bulk_export_meetings,
|
||||
commands::import_meeting_bundle,
|
||||
commands::rename_speaker,
|
||||
commands::merge_speakers,
|
||||
commands::map_speaker_to_participant,
|
||||
@@ -200,12 +265,18 @@ pub fn run() {
|
||||
commands::set_llm_provider,
|
||||
commands::generate_summary,
|
||||
commands::confirm_action_items,
|
||||
commands::generate_tags,
|
||||
commands::llm_setup_suggestions,
|
||||
commands::pull_ollama_model,
|
||||
commands::import_pst,
|
||||
commands::cleanup_calendar_events,
|
||||
commands::list_calendar_events,
|
||||
commands::get_calendar_event,
|
||||
commands::attach_meeting_to_event,
|
||||
commands::begin_graph_calendar_link,
|
||||
commands::import_graph_calendar,
|
||||
commands::disconnect_graph_calendar,
|
||||
commands::rename_meeting,
|
||||
commands::list_sync_targets,
|
||||
commands::add_sync_target,
|
||||
commands::update_sync_target,
|
||||
@@ -245,3 +316,49 @@ pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) {
|
||||
let _ = tray.0.set_tooltip(Some(text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for `whispassist.exe --mcp-stdio` (FR-MCP-6): serves one MCP
|
||||
/// session over this process's own stdin/stdout instead of showing a window,
|
||||
/// against the same `wa.db` the GUI instance uses. There is no Tauri
|
||||
/// `AppHandle` in this mode, so `mcp://access` events have nowhere to go —
|
||||
/// the `mcp_access_log` DB row is still written regardless (FR-MCP-5).
|
||||
pub fn run_mcp_stdio() {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
// stderr, not stdout: stdout is the MCP JSON-RPC channel.
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter("info")
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("whispassist --mcp-stdio: failed to start a runtime: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
rt.block_on(async {
|
||||
let store: std::sync::Arc<dyn storage::Store> =
|
||||
match storage::SqliteStore::connect().await {
|
||||
Ok(s) => std::sync::Arc::new(s),
|
||||
Err(e) => {
|
||||
eprintln!("whispassist --mcp-stdio: failed to open wa.db: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let handler = mcp::handler::WaMcpHandler::new(store, None);
|
||||
if let Err(e) = mcp::stdio_transport::serve_once(handler).await {
|
||||
eprintln!("whispassist --mcp-stdio: session ended with an error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
eprintln!("this build was compiled without MCP support (the `mcp` cargo feature is off)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+907
-19
File diff suppressed because it is too large
Load Diff
@@ -3,5 +3,14 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// `whispassist.exe --mcp-stdio` (FR-MCP-6): the stdio "adapter the agent
|
||||
// spawns" is this same binary, in headless mode -- it serves one MCP
|
||||
// session over its own stdin/stdout and exits, instead of opening the
|
||||
// GUI window. A coding agent's MCP client config spawns this exact
|
||||
// command line (see `mcp_status`/`set_mcp_enabled`'s returned endpoint).
|
||||
if std::env::args().any(|a| a == "--mcp-stdio") {
|
||||
whispassist_lib::run_mcp_stdio();
|
||||
return;
|
||||
}
|
||||
whispassist_lib::run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! `rmcp::ServerHandler` implementation — the tools-first surface (FR-MCP-2)
|
||||
//! that a connected coding agent actually calls. Every tool handler:
|
||||
//! 1. Reads the *current* scope from `Settings` (not a snapshot taken at
|
||||
//! server start) so `set_mcp_scope` takes effect immediately.
|
||||
//! 2. Logs the read (FR-MCP-5) — even when the read is denied, so the audit
|
||||
//! trail reflects what an agent *asked for*.
|
||||
//! 3. Independently re-checks scope + the recordings gate (FR-MCP-3) — there
|
||||
//! is deliberately no single choke point upstream of this file.
|
||||
|
||||
use crate::mcp::{scope, ExposeScope};
|
||||
use crate::models::MeetingId;
|
||||
use crate::storage::{MeetingFilter, Store};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
|
||||
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::{ErrorData as McpProtoError, ServerHandler};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// Shared handle the HTTP/stdio transports build a fresh `rmcp` service
|
||||
/// around per-connection (`ServerHandler` methods take `&self`, so this just
|
||||
/// needs to be `Clone` + cheap — it's an `Arc<Store>` and an `AppHandle`).
|
||||
/// `app` is `None` in `--mcp-stdio` mode (a separate process with no Tauri
|
||||
/// window to emit events to, see `mcp::stdio_transport`/`main.rs`) — the
|
||||
/// `mcp_access_log` row is still written either way (FR-MCP-5), only the
|
||||
/// live `"mcp://access"` event has nowhere to go.
|
||||
#[derive(Clone)]
|
||||
pub struct WaMcpHandler {
|
||||
store: Arc<dyn Store>,
|
||||
app: Option<AppHandle>,
|
||||
}
|
||||
|
||||
impl WaMcpHandler {
|
||||
pub fn new(store: Arc<dyn Store>, app: Option<AppHandle>) -> Self {
|
||||
Self { store, app }
|
||||
}
|
||||
|
||||
/// Live scope read (not cached) so `set_mcp_scope` applies without a
|
||||
/// server restart.
|
||||
fn current_scope(&self) -> (ExposeScope, bool) {
|
||||
let settings = crate::commands::load_settings();
|
||||
(
|
||||
ExposeScope::parse(&settings.mcp_expose),
|
||||
settings.mcp_expose_recordings,
|
||||
)
|
||||
}
|
||||
|
||||
async fn log_access(&self, tool: &str, meeting_id: Option<&MeetingId>, client: Option<&str>) {
|
||||
if let Err(e) = self.store.record_mcp_access(tool, meeting_id, client).await {
|
||||
tracing::warn!("failed to record mcp access log row: {e}");
|
||||
}
|
||||
if let Some(app) = &self.app {
|
||||
let _ = app.emit(
|
||||
"mcp://access",
|
||||
json!({
|
||||
"at": now_ms(),
|
||||
"tool": tool,
|
||||
"meetingId": meeting_id,
|
||||
"client": client,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn client_name(context: &RequestContext<RoleServer>) -> Option<String> {
|
||||
context
|
||||
.peer
|
||||
.peer_info()
|
||||
.map(|info| info.client_info.name.clone())
|
||||
}
|
||||
|
||||
async fn tool_list_recent_meetings(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
self.log_access("list_recent_meetings", None, client).await;
|
||||
let (scope_val, expose_recordings) = self.current_scope();
|
||||
if !scope::meetings_visible(scope_val) {
|
||||
return Ok(CallToolResult::structured(json!({ "meetings": [] })));
|
||||
}
|
||||
let limit = arg_u64(args, "limit").unwrap_or(20).clamp(1, 100) as usize;
|
||||
let items = self
|
||||
.store
|
||||
.list_meetings(MeetingFilter::default())
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
let mut out = Vec::with_capacity(limit);
|
||||
for item in items {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let Ok(full) = self.store.get_meeting(&item.id).await else {
|
||||
continue;
|
||||
};
|
||||
if !scope::recording_gate_ok(expose_recordings, full.recorded) {
|
||||
continue;
|
||||
}
|
||||
out.push(json!({
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"startedAt": item.started_at,
|
||||
"durationSecs": item.duration_secs,
|
||||
"status": item.status.as_str(),
|
||||
"tags": item.tags,
|
||||
}));
|
||||
}
|
||||
Ok(CallToolResult::structured(json!({ "meetings": out })))
|
||||
}
|
||||
|
||||
async fn tool_get_transcript(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let meeting_id = arg_str(args, "meetingId")
|
||||
.ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?;
|
||||
self.log_access("get_transcript", Some(&meeting_id), client)
|
||||
.await;
|
||||
let (scope_val, expose_recordings) = self.current_scope();
|
||||
if !scope::meetings_visible(scope_val) {
|
||||
return Ok(denied("get_transcript scope is not `all`"));
|
||||
}
|
||||
let meeting = self
|
||||
.store
|
||||
.get_meeting(&meeting_id)
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
if !scope::recording_gate_ok(expose_recordings, meeting.recorded) {
|
||||
return Ok(denied(
|
||||
"this meeting retained its recording; expose_recordings is off",
|
||||
));
|
||||
}
|
||||
Ok(CallToolResult::structured(json!({
|
||||
"meetingId": meeting.id,
|
||||
"title": meeting.title,
|
||||
"segments": meeting.segments,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn tool_get_action_items(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let meeting_id = arg_str(args, "meetingId")
|
||||
.ok_or_else(|| McpProtoError::invalid_params("meetingId is required", None))?;
|
||||
self.log_access("get_action_items", Some(&meeting_id), client)
|
||||
.await;
|
||||
let (scope_val, expose_recordings) = self.current_scope();
|
||||
if !scope::meetings_visible(scope_val) {
|
||||
return Ok(denied("get_action_items scope is not `all`"));
|
||||
}
|
||||
let meeting = self
|
||||
.store
|
||||
.get_meeting(&meeting_id)
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
if !scope::recording_gate_ok(expose_recordings, meeting.recorded) {
|
||||
return Ok(denied(
|
||||
"this meeting retained its recording; expose_recordings is off",
|
||||
));
|
||||
}
|
||||
let items = self
|
||||
.store
|
||||
.list_action_items(&meeting_id)
|
||||
.await
|
||||
.map_err(store_err)?;
|
||||
Ok(CallToolResult::structured(json!({
|
||||
"meetingId": meeting_id,
|
||||
"items": items,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn tool_get_feature_brief(
|
||||
&self,
|
||||
args: &Option<JsonObject>,
|
||||
client: Option<&str>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let id = arg_str(args, "id")
|
||||
.ok_or_else(|| McpProtoError::invalid_params("id is required", None))?;
|
||||
self.log_access("get_feature_brief", None, client).await;
|
||||
let (scope_val, _expose_recordings) = self.current_scope();
|
||||
if matches!(scope_val, ExposeScope::None) {
|
||||
return Ok(denied("MCP scope is `none`; no briefs are exposed"));
|
||||
}
|
||||
let row = match self.store.get_feature_brief_row(&id).await {
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
return Ok(CallToolResult::structured_error(json!({
|
||||
"error": "storage",
|
||||
"message": e.to_string(),
|
||||
})))
|
||||
}
|
||||
};
|
||||
if !scope::brief_visible(scope_val, row.exposed) {
|
||||
return Ok(denied(
|
||||
"this brief is not exposed (toggle it on via set_brief_exposed, or set scope to `all`)",
|
||||
));
|
||||
}
|
||||
match crate::commands::get_feature_brief_core(&self.store, &id).await {
|
||||
Ok(brief) => Ok(CallToolResult::structured(
|
||||
serde_json::to_value(brief)
|
||||
.map_err(|e| McpProtoError::internal_error(e.to_string(), None))?,
|
||||
)),
|
||||
Err(e) => Ok(CallToolResult::structured_error(json!({
|
||||
"error": e.kind,
|
||||
"message": e.message,
|
||||
}))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for WaMcpHandler {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation {
|
||||
name: "whispassist".into(),
|
||||
title: Some("WhispAssist".into()),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
description: None,
|
||||
icons: None,
|
||||
website_url: None,
|
||||
},
|
||||
instructions: Some(
|
||||
"WhispAssist meeting-assistant tools. Served data may be forwarded by this \
|
||||
agent to its own model provider outside WhispAssist's control -- WA discloses \
|
||||
this in its UI and logs every read (FR-MCP-5). Recordings (.wav) are never \
|
||||
served by any tool here."
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, McpProtoError> {
|
||||
let tools = vec![
|
||||
Tool::new(
|
||||
"list_recent_meetings",
|
||||
"Recent meetings, most recent first (scoped by the user's MCP settings).",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "limit": { "type": "integer", "minimum": 1, "maximum": 100 } },
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
Tool::new(
|
||||
"get_transcript",
|
||||
"Full transcript (speaker-labeled segments) for one meeting.",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "meetingId": { "type": "string" } },
|
||||
"required": ["meetingId"],
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
Tool::new(
|
||||
"get_action_items",
|
||||
"Confirmed action items for one meeting.",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "meetingId": { "type": "string" } },
|
||||
"required": ["meetingId"],
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
Tool::new(
|
||||
"get_feature_brief",
|
||||
"Agent-ready spec (problem/outcome/acceptance criteria) distilled from a meeting.",
|
||||
obj_schema(json!({
|
||||
"type": "object",
|
||||
"properties": { "id": { "type": "string" } },
|
||||
"required": ["id"],
|
||||
"additionalProperties": false,
|
||||
})),
|
||||
),
|
||||
];
|
||||
Ok(ListToolsResult::with_all_items(tools))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, McpProtoError> {
|
||||
let client = Self::client_name(&context);
|
||||
match request.name.as_ref() {
|
||||
"list_recent_meetings" => {
|
||||
self.tool_list_recent_meetings(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
"get_transcript" => {
|
||||
self.tool_get_transcript(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
"get_action_items" => {
|
||||
self.tool_get_action_items(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
"get_feature_brief" => {
|
||||
self.tool_get_feature_brief(&request.arguments, client.as_deref())
|
||||
.await
|
||||
}
|
||||
other => Err(McpProtoError::invalid_params(
|
||||
format!("unknown tool: {other}"),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn obj_schema(value: Value) -> Arc<JsonObject> {
|
||||
Arc::new(value.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn arg_str(args: &Option<JsonObject>, key: &str) -> Option<String> {
|
||||
args.as_ref()?.get(key)?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
fn arg_u64(args: &Option<JsonObject>, key: &str) -> Option<u64> {
|
||||
args.as_ref()?.get(key)?.as_u64()
|
||||
}
|
||||
|
||||
fn store_err(e: crate::storage::StoreError) -> McpProtoError {
|
||||
McpProtoError::internal_error(e.to_string(), None)
|
||||
}
|
||||
|
||||
fn denied(reason: &str) -> CallToolResult {
|
||||
CallToolResult::structured_error(json!({ "error": "scope_denied", "message": reason }))
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Streamable HTTP transport (FR-MCP-6): loopback-only bind + a bearer-token
|
||||
//! gate that runs in front of every connection, before a single byte reaches
|
||||
//! the MCP service. `rmcp`'s `StreamableHttpService` is a bare
|
||||
//! `tower_service::Service` (not an axum app), so this module supplies the
|
||||
//! actual TCP accept loop + HTTP/1 framing via `hyper`.
|
||||
|
||||
use crate::mcp::handler::WaMcpHandler;
|
||||
use crate::mcp::{token, McpError};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{combinators::BoxBody, BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Binds `host:port`, refusing anything that doesn't resolve to a loopback
|
||||
/// address (127.0.0.0/8 or ::1) -- the only way this crate ever opens a
|
||||
/// listening socket for MCP (FR-MCP-1, NFR-SEC-5). Kept generic over `host`
|
||||
/// purely so the refusal path is directly unit-testable; the only production
|
||||
/// caller (`mcp::server`) always passes `"127.0.0.1"`.
|
||||
pub(crate) async fn bind_loopback(host: &str, port: u16) -> Result<TcpListener, McpError> {
|
||||
let ip: IpAddr = host.parse().map_err(|_| McpError::NonLoopback)?;
|
||||
if !ip.is_loopback() {
|
||||
return Err(McpError::NonLoopback);
|
||||
}
|
||||
TcpListener::bind(SocketAddr::new(ip, port))
|
||||
.await
|
||||
.map_err(|e| McpError::Server(e.to_string()))
|
||||
}
|
||||
|
||||
/// A running HTTP server; `stop()` cancels the accept loop and all live
|
||||
/// connections and waits for cleanup.
|
||||
pub(crate) struct HttpServerHandle {
|
||||
pub local_addr: SocketAddr,
|
||||
shutdown: CancellationToken,
|
||||
join: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl HttpServerHandle {
|
||||
pub async fn stop(self) {
|
||||
self.shutdown.cancel();
|
||||
let _ = self.join.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the MCP Streamable HTTP endpoint (`/mcp`, per the config's session
|
||||
/// routing) on an already-bound loopback listener. Every request must present
|
||||
/// `Authorization: Bearer <token>` matching the stored token (constant-time
|
||||
/// compare, `mcp::token::verify`) or it never reaches `rmcp`.
|
||||
pub(crate) fn serve(
|
||||
listener: TcpListener,
|
||||
expected_token: String,
|
||||
handler: WaMcpHandler,
|
||||
) -> HttpServerHandle {
|
||||
let local_addr = listener
|
||||
.local_addr()
|
||||
.expect("a just-bound TcpListener has a local addr");
|
||||
let shutdown = CancellationToken::new();
|
||||
|
||||
let config = StreamableHttpServerConfig {
|
||||
stateful_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let session_manager = Arc::new(LocalSessionManager::default());
|
||||
let service = StreamableHttpService::new(move || Ok(handler.clone()), session_manager, config);
|
||||
|
||||
let accept_ct = shutdown.clone();
|
||||
let join = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = accept_ct.cancelled() => break,
|
||||
accepted = listener.accept() => {
|
||||
let Ok((stream, _peer)) = accepted else { continue };
|
||||
let io = TokioIo::new(stream);
|
||||
let svc = service.clone();
|
||||
let token = expected_token.clone();
|
||||
let conn_ct = accept_ct.clone();
|
||||
tokio::spawn(async move {
|
||||
let guarded = service_fn(move |req: Request<Incoming>| {
|
||||
let mut svc = svc.clone();
|
||||
let token = token.clone();
|
||||
async move { Ok::<_, Infallible>(handle_request(req, &mut svc, &token).await) }
|
||||
});
|
||||
let conn = hyper::server::conn::http1::Builder::new().serve_connection(io, guarded);
|
||||
tokio::select! {
|
||||
_ = conn_ct.cancelled() => {}
|
||||
_ = conn => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
HttpServerHandle {
|
||||
local_addr,
|
||||
shutdown,
|
||||
join,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
req: Request<Incoming>,
|
||||
svc: &mut StreamableHttpService<WaMcpHandler>,
|
||||
expected_token: &str,
|
||||
) -> Response<BoxBody<Bytes, Infallible>> {
|
||||
if !is_authorized(&req, expected_token) {
|
||||
return unauthorized_response();
|
||||
}
|
||||
let (parts, body) = req.into_parts();
|
||||
let req = Request::from_parts(parts, body.boxed());
|
||||
tower_service::Service::call(svc, req)
|
||||
.await
|
||||
.unwrap_or_else(|never: Infallible| match never {})
|
||||
}
|
||||
|
||||
fn is_authorized(req: &Request<Incoming>, expected_token: &str) -> bool {
|
||||
let Some(header) = req.headers().get(hyper::header::AUTHORIZATION) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(header) = header.to_str() else {
|
||||
return false;
|
||||
};
|
||||
let Some(presented) = header.strip_prefix("Bearer ") else {
|
||||
return false;
|
||||
};
|
||||
token::verify(presented, expected_token)
|
||||
}
|
||||
|
||||
fn unauthorized_response() -> Response<BoxBody<Bytes, Infallible>> {
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(hyper::header::CONTENT_TYPE, "application/json")
|
||||
.body(Full::new(Bytes::from_static(b"{\"error\":\"unauthorized\"}")).boxed())
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_a_non_loopback_bind() {
|
||||
// A real routable address is never allowed regardless of port
|
||||
// availability -- the check happens before any socket syscall.
|
||||
let err = bind_loopback("8.8.8.8", 0).await.unwrap_err();
|
||||
assert!(matches!(err, McpError::NonLoopback));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_an_unparseable_host() {
|
||||
let err = bind_loopback("not-an-ip", 0).await.unwrap_err();
|
||||
assert!(matches!(err, McpError::NonLoopback));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binds_127_0_0_1_on_an_os_assigned_port() {
|
||||
let listener = bind_loopback("127.0.0.1", 0).await.expect("loopback bind");
|
||||
assert!(listener.local_addr().unwrap().ip().is_loopback());
|
||||
}
|
||||
}
|
||||
+81
-40
@@ -14,10 +14,28 @@
|
||||
use crate::models::{FeatureBrief, MeetingId};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub mod scope;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod handler;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod http_transport;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod server;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod stdio_transport;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod token;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub use server::RmcpServer;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum McpError {
|
||||
#[error("refusing to bind non-loopback address")]
|
||||
NonLoopback,
|
||||
#[error("unauthorized: missing or invalid token")]
|
||||
Unauthorized,
|
||||
#[error("server error: {0}")]
|
||||
Server(String),
|
||||
}
|
||||
@@ -38,7 +56,7 @@ pub struct McpConfig {
|
||||
pub expose_recordings: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpTransport {
|
||||
/// Streamable HTTP on http://127.0.0.1:<port>/mcp (loopback only).
|
||||
Http,
|
||||
@@ -46,24 +64,85 @@ pub enum McpTransport {
|
||||
Stdio,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
impl McpTransport {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
McpTransport::Http => "http",
|
||||
McpTransport::Stdio => "stdio",
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown/missing values fall back to `Http` — the safer default to
|
||||
/// document to the user (stdio requires a client that spawns a process).
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"stdio" => McpTransport::Stdio,
|
||||
_ => McpTransport::Http,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExposeScope {
|
||||
None,
|
||||
Selected,
|
||||
All,
|
||||
}
|
||||
|
||||
impl ExposeScope {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ExposeScope::None => "none",
|
||||
ExposeScope::Selected => "selected",
|
||||
ExposeScope::All => "all",
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown values fall back to `None` — scope-control is a privacy
|
||||
/// control, so an unparsed value must never silently become permissive.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"selected" => ExposeScope::Selected,
|
||||
"all" => ExposeScope::All,
|
||||
_ => ExposeScope::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned on start: where to point the agent + the token it must present.
|
||||
pub struct McpHandle {
|
||||
pub endpoint: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct McpToolDescriptor {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// The four tools-first-surface descriptors (FR-MCP-2), shared by the trait's
|
||||
/// default listing and anything else that needs to enumerate them without a
|
||||
/// running server (e.g. the settings/privacy UI).
|
||||
pub const TOOL_DESCRIPTORS: [McpToolDescriptor; 4] = [
|
||||
McpToolDescriptor {
|
||||
name: "list_recent_meetings",
|
||||
description: "Recent meetings (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_transcript",
|
||||
description: "Transcript for a meeting (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_action_items",
|
||||
description: "Action items for a meeting.",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_feature_brief",
|
||||
description: "Agent-ready spec distilled from a meeting.",
|
||||
},
|
||||
];
|
||||
|
||||
/// The MCP server. Built on the official Rust SDK (`rmcp`, feature `mcp`).
|
||||
#[async_trait]
|
||||
pub trait McpServer: Send + Sync {
|
||||
@@ -82,41 +161,3 @@ pub trait FeatureBriefBuilder: Send + Sync {
|
||||
target_repo: Option<&str>,
|
||||
) -> Result<FeatureBrief, BriefError>;
|
||||
}
|
||||
|
||||
/// Default rmcp-backed server (feature `mcp`).
|
||||
#[cfg(feature = "mcp")]
|
||||
pub struct RmcpServer;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
#[async_trait]
|
||||
impl McpServer for RmcpServer {
|
||||
async fn start(&self, _cfg: McpConfig) -> Result<McpHandle, McpError> {
|
||||
// T10.4: bind loopback ONLY (reject non-loopback), mint a token, register tools,
|
||||
// serve over Streamable HTTP (/mcp) or stdio. Never opens an outbound socket.
|
||||
todo!("Phase 10b — start MCP server (loopback, token)")
|
||||
}
|
||||
async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> {
|
||||
todo!("Phase 10b — stop MCP server")
|
||||
}
|
||||
fn tools(&self) -> Vec<McpToolDescriptor> {
|
||||
// T10.5: the tools an agent can call. Tools-first for Copilot compatibility.
|
||||
vec![
|
||||
McpToolDescriptor {
|
||||
name: "list_recent_meetings",
|
||||
description: "Recent meetings (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_transcript",
|
||||
description: "Transcript for a meeting (scoped).",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_action_items",
|
||||
description: "Action items for a meeting.",
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "get_feature_brief",
|
||||
description: "Agent-ready spec distilled from a meeting.",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Pure scope-control logic (FR-MCP-3), split out from `mcp/mod.rs` so it's
|
||||
//! unit-testable without a DB, a running server, or the `mcp` cargo feature.
|
||||
//!
|
||||
//! Design note (documented here because the schema doesn't (yet) carry a
|
||||
//! per-meeting "expose this meeting" flag -- only `feature_briefs.exposed`
|
||||
//! does, per `docs/03-data-model.md`): with `ExposeScope::Selected`, meetings/
|
||||
//! transcripts/action-items have no selection mechanism to key off in this
|
||||
//! milestone, so they are treated the same as `None` (deny) rather than the
|
||||
//! same as `All` (allow) -- a privacy-conservative default consistent with
|
||||
//! every other WA default (recording/sync/hosted-AI/MCP itself all default
|
||||
//! OFF). Only `get_feature_brief` has real per-item selection today, via the
|
||||
//! brief's own `exposed` flag (M1). A future "select meetings" UI/schema
|
||||
//! addition should upgrade `Selected` for the other three tools without
|
||||
//! changing this function's callers.
|
||||
|
||||
use crate::mcp::ExposeScope;
|
||||
|
||||
/// Whether `list_recent_meetings`/`get_transcript`/`get_action_items` may see
|
||||
/// meetings at all under the current scope. `Selected` has no per-meeting
|
||||
/// selection mechanism yet (see module docs) so it is conservatively treated
|
||||
/// like `None`.
|
||||
pub fn meetings_visible(scope: ExposeScope) -> bool {
|
||||
matches!(scope, ExposeScope::All)
|
||||
}
|
||||
|
||||
/// Whether a specific feature brief may be served. `exposed` is the brief's
|
||||
/// own per-item flag (`feature_briefs.exposed`, set via `set_brief_exposed`).
|
||||
pub fn brief_visible(scope: ExposeScope, exposed: bool) -> bool {
|
||||
match scope {
|
||||
ExposeScope::None => false,
|
||||
ExposeScope::Selected => exposed,
|
||||
ExposeScope::All => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recordings (`.wav`) are never exposed unless explicitly allowed (FR-MCP-3),
|
||||
/// independent of `ExposeScope`. None of the four MCP tools serve raw audio
|
||||
/// bytes today, but a meeting that retained its recording (ADR-0009) is
|
||||
/// treated as more sensitive-by-association: its transcript/action items are
|
||||
/// also withheld unless the user opted into `expose_recordings`. Every tool
|
||||
/// handler must call this for each candidate meeting -- there is no central
|
||||
/// choke point (FR-MCP-3 "enforce in every tool handler").
|
||||
pub fn recording_gate_ok(expose_recordings: bool, meeting_recorded: bool) -> bool {
|
||||
expose_recordings || !meeting_recorded
|
||||
}
|
||||
|
||||
/// Combined check a tool handler runs before including one meeting's data.
|
||||
pub fn meeting_allowed(
|
||||
scope: ExposeScope,
|
||||
expose_recordings: bool,
|
||||
meeting_recorded: bool,
|
||||
) -> bool {
|
||||
meetings_visible(scope) && recording_gate_ok(expose_recordings, meeting_recorded)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn none_hides_all_meetings() {
|
||||
assert!(!meetings_visible(ExposeScope::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_hides_meetings_pending_a_selection_mechanism() {
|
||||
// Documented conservative choice -- see module docs.
|
||||
assert!(!meetings_visible(ExposeScope::Selected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_shows_meetings() {
|
||||
assert!(meetings_visible(ExposeScope::All));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brief_visibility_follows_the_exposed_flag_only_under_selected() {
|
||||
assert!(!brief_visible(ExposeScope::None, true));
|
||||
assert!(!brief_visible(ExposeScope::Selected, false));
|
||||
assert!(brief_visible(ExposeScope::Selected, true));
|
||||
assert!(brief_visible(ExposeScope::All, false));
|
||||
assert!(brief_visible(ExposeScope::All, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recordings_never_served_unless_explicitly_allowed() {
|
||||
assert!(!recording_gate_ok(false, true));
|
||||
assert!(recording_gate_ok(false, false));
|
||||
assert!(recording_gate_ok(true, true));
|
||||
assert!(recording_gate_ok(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meeting_allowed_requires_both_scope_and_recording_gate() {
|
||||
assert!(!meeting_allowed(ExposeScope::All, false, true)); // recorded, not opted-in
|
||||
assert!(meeting_allowed(ExposeScope::All, false, false)); // not recorded
|
||||
assert!(meeting_allowed(ExposeScope::All, true, true)); // opted-in
|
||||
assert!(!meeting_allowed(ExposeScope::None, true, false)); // scope still wins
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! `RmcpServer` — the concrete `McpServer` implementation (T10.4). Owns the
|
||||
//! one running transport (HTTP listener, if any) so `stop()` can tear it
|
||||
//! down; a process-wide singleton (`instance`) is what `commands.rs` reaches
|
||||
//! for, since Tauri command handlers are separate calls with no shared state
|
||||
//! of their own beyond `AppState`.
|
||||
|
||||
use crate::mcp::handler::WaMcpHandler;
|
||||
use crate::mcp::{
|
||||
http_transport, token, McpConfig, McpError, McpHandle, McpServer, McpToolDescriptor,
|
||||
McpTransport,
|
||||
};
|
||||
use crate::storage::Store;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tauri::AppHandle;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
enum Running {
|
||||
Http(http_transport::HttpServerHandle),
|
||||
/// stdio has nothing running *in this process* — the agent spawns its
|
||||
/// own `--mcp-stdio` child (see `mcp::stdio_transport`); this variant
|
||||
/// just records "enabled" for `mcp_status`.
|
||||
Stdio,
|
||||
}
|
||||
|
||||
pub struct RmcpServer {
|
||||
store: Arc<dyn Store>,
|
||||
app: AppHandle,
|
||||
running: Mutex<Option<Running>>,
|
||||
}
|
||||
|
||||
impl RmcpServer {
|
||||
pub fn new(store: Arc<dyn Store>, app: AppHandle) -> Self {
|
||||
Self {
|
||||
store,
|
||||
app,
|
||||
running: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_running(&self) {
|
||||
if let Some(Running::Http(handle)) = self.running.lock().await.take() {
|
||||
handle.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` once a `start()` has actually taken effect (HTTP listener bound
|
||||
/// or stdio mode recorded) — used by `mcp_status`.
|
||||
pub async fn is_running(&self) -> bool {
|
||||
self.running.lock().await.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpServer for RmcpServer {
|
||||
async fn start(&self, cfg: McpConfig) -> Result<McpHandle, McpError> {
|
||||
// Re-enabling (or switching transport/port) replaces whatever was running.
|
||||
self.stop_running().await;
|
||||
let auth_token = token::mint_and_store()?;
|
||||
|
||||
match cfg.transport {
|
||||
McpTransport::Http => {
|
||||
let listener = http_transport::bind_loopback("127.0.0.1", cfg.port).await?;
|
||||
let handler = WaMcpHandler::new(self.store.clone(), Some(self.app.clone()));
|
||||
let handle = http_transport::serve(listener, auth_token.clone(), handler);
|
||||
let endpoint = format!("http://{}/mcp", handle.local_addr);
|
||||
*self.running.lock().await = Some(Running::Http(handle));
|
||||
Ok(McpHandle {
|
||||
endpoint,
|
||||
token: auth_token,
|
||||
})
|
||||
}
|
||||
McpTransport::Stdio => {
|
||||
*self.running.lock().await = Some(Running::Stdio);
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "whispassist.exe".to_string());
|
||||
Ok(McpHandle {
|
||||
endpoint: format!("{exe} --mcp-stdio"),
|
||||
token: auth_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop(&self, _handle: McpHandle) -> Result<(), McpError> {
|
||||
self.stop_running().await;
|
||||
token::delete();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tools(&self) -> Vec<McpToolDescriptor> {
|
||||
crate::mcp::TOOL_DESCRIPTORS.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
static INSTANCE: OnceLock<Arc<RmcpServer>> = OnceLock::new();
|
||||
|
||||
/// The process-wide `RmcpServer`. `store`/`app` are only used on the first
|
||||
/// call (they're the same `AppState`/`AppHandle` for the process's whole
|
||||
/// life); later calls just return the existing instance.
|
||||
pub fn instance(store: Arc<dyn Store>, app: AppHandle) -> Arc<RmcpServer> {
|
||||
INSTANCE
|
||||
.get_or_init(|| Arc::new(RmcpServer::new(store, app)))
|
||||
.clone()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! stdio transport (FR-MCP-6) — "a thin adapter the agent spawns". A coding
|
||||
//! agent's MCP client config spawns `whispassist.exe --mcp-stdio` and talks
|
||||
//! JSON-RPC over that child process's stdin/stdout; `main.rs` checks for that
|
||||
//! flag before building the Tauri window and calls `serve_once` here instead.
|
||||
//!
|
||||
//! There is no bearer-token header to check here (unlike HTTP): the ability
|
||||
//! to spawn this process at all already requires the same OS-level privilege
|
||||
//! as running any other local command as the signed-in user, so process-spawn
|
||||
//! capability is the trust boundary for stdio, same as other local-only MCP
|
||||
//! servers. `set_mcp_enabled` still mints/stores a token (`mcp::token`) for
|
||||
//! parity with the HTTP transport and in case a future stdio client wants to
|
||||
//! pass it, but this transport does not require presenting it.
|
||||
|
||||
use crate::mcp::handler::WaMcpHandler;
|
||||
use crate::mcp::McpError;
|
||||
use rmcp::ServiceExt;
|
||||
|
||||
/// Serves one MCP session over the current process's stdin/stdout until the
|
||||
/// peer disconnects, then returns.
|
||||
pub async fn serve_once(handler: WaMcpHandler) -> Result<(), McpError> {
|
||||
let transport = rmcp::transport::io::stdio();
|
||||
let running = handler
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| McpError::Server(e.to_string()))?;
|
||||
running
|
||||
.waiting()
|
||||
.await
|
||||
.map_err(|e| McpError::Server(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! MCP auth-token storage (FR-MCP-1/6). The token itself is **never** written
|
||||
//! to `settings.json`/`wa.db`/logs — only the OS credential store, exactly
|
||||
//! like sync secrets (`sync::credentials`) and hosted-AI API keys.
|
||||
|
||||
use crate::mcp::McpError;
|
||||
|
||||
const SERVICE: &str = "WhispAssist-mcp";
|
||||
const ACCOUNT: &str = "token";
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
|
||||
fn entry() -> Result<keyring::Entry, McpError> {
|
||||
keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| McpError::Server(e.to_string()))
|
||||
}
|
||||
|
||||
/// Generates a fresh random token (hex-encoded, 64 chars) and persists it,
|
||||
/// replacing whatever was there before (each `set_mcp_enabled` mints a new
|
||||
/// one — there is no "reveal the existing token" path, same treatment as a
|
||||
/// password).
|
||||
pub fn mint_and_store() -> Result<String, McpError> {
|
||||
let mut buf = [0u8; TOKEN_BYTES];
|
||||
getrandom::getrandom(&mut buf).map_err(|e| McpError::Server(e.to_string()))?;
|
||||
let token = hex_encode(&buf);
|
||||
entry()?
|
||||
.set_password(&token)
|
||||
.map_err(|e| McpError::Server(e.to_string()))?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Best-effort read for `mcp_status`'s `tokenSet` flag — never returned to
|
||||
/// the frontend as a value, only whether one exists.
|
||||
pub fn is_set() -> bool {
|
||||
entry()
|
||||
.and_then(|e| {
|
||||
e.get_password()
|
||||
.map_err(|e| McpError::Server(e.to_string()))
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn get() -> Result<String, McpError> {
|
||||
entry()?
|
||||
.get_password()
|
||||
.map_err(|e| McpError::Server(e.to_string()))
|
||||
}
|
||||
|
||||
/// Best-effort cleanup on disable — a missing entry is not an error.
|
||||
pub fn delete() {
|
||||
if let Ok(e) = entry() {
|
||||
match e.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
||||
Err(err) => tracing::warn!("failed to delete MCP token: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-time comparison so token checking doesn't leak timing
|
||||
/// information about how many leading bytes matched (NFR-SEC-5).
|
||||
pub fn verify(presented: &str, expected: &str) -> bool {
|
||||
let a = presented.as_bytes();
|
||||
let b = expected.as_bytes();
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn verify_requires_exact_match() {
|
||||
assert!(verify("abc123", "abc123"));
|
||||
assert!(!verify("abc123", "abc124"));
|
||||
assert!(!verify("abc12", "abc123"));
|
||||
assert!(!verify("", "abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_encode_is_lowercase_and_fixed_width() {
|
||||
assert_eq!(hex_encode(&[0, 255, 16]), "00ff10");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Media import: turn an arbitrary local audio/video file, a direct media URL,
|
||||
//! or a streaming/YouTube page URL into WhispAssist's canonical 16kHz-mono
|
||||
//! 16-bit WAV, so it can go through the same transcription/diarization path as a
|
||||
//! live recording (manual "add a meeting from a file/URL" feature).
|
||||
//!
|
||||
//! Two external tools do the work and are deliberately **not bundled** (same
|
||||
//! call as `readpst` for .pst, ADR-0008): they must be installed and on PATH.
|
||||
//! - `ffmpeg` transcodes whatever we have to the target WAV.
|
||||
//! - `yt-dlp` resolves URLs (YouTube and other sites via its extractors, and
|
||||
//! direct media URLs via its generic extractor) down to an audio file that
|
||||
//! ffmpeg can then convert.
|
||||
//!
|
||||
//! A missing tool surfaces as a clear, named error rather than a generic
|
||||
//! "program not found".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MediaError {
|
||||
#[error("{0} isn't installed or on PATH — install it and try again")]
|
||||
ToolMissing(&'static str),
|
||||
#[error("{tool} failed: {message}")]
|
||||
Failed {
|
||||
tool: &'static str,
|
||||
message: String,
|
||||
},
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Whether `source` should be resolved as a URL (via yt-dlp) rather than opened
|
||||
/// as a local file path.
|
||||
pub fn is_url(source: &str) -> bool {
|
||||
let s = source.trim_start();
|
||||
s.starts_with("http://") || s.starts_with("https://")
|
||||
}
|
||||
|
||||
/// A `Command` for `program` that, on Windows, never pops a console window —
|
||||
/// WhispAssist is a GUI app with no console of its own (same fix as `readpst`).
|
||||
fn command(program: &str) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run `cmd`, mapping a missing binary to `ToolMissing(program)` and a non-zero
|
||||
/// exit to `Failed` carrying the tail of the tool's output (where the real
|
||||
/// error message from ffmpeg/yt-dlp lives — both are verbose).
|
||||
fn run(program: &'static str, cmd: &mut Command) -> Result<(), MediaError> {
|
||||
let output = cmd.output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => MediaError::ToolMissing(program),
|
||||
_ => MediaError::Io(e),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let text = if stderr.trim().is_empty() { stdout } else { stderr };
|
||||
let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let start = tail.len().saturating_sub(6);
|
||||
return Err(MediaError::Failed {
|
||||
tool: program,
|
||||
message: tail[start..].join("\n"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transcode `source` (a local file path or a URL) into a 16kHz-mono 16-bit WAV
|
||||
/// at `dest_wav`. URLs are first fetched with yt-dlp into `work_dir` (which the
|
||||
/// caller creates and cleans up), then ffmpeg converts whatever landed. Blocks;
|
||||
/// run it off the async runtime.
|
||||
pub fn import_to_wav(source: &str, dest_wav: &Path, work_dir: &Path) -> Result<(), MediaError> {
|
||||
if is_url(source) {
|
||||
// bestaudio keeps the download small; ffmpeg does the actual 16kHz-mono
|
||||
// conversion in one predictable pass. `--no-part` avoids a leftover
|
||||
// `.part` file so `first_file_in` finds the finished download.
|
||||
let template = work_dir.join("download.%(ext)s");
|
||||
let mut cmd = command("yt-dlp");
|
||||
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-part", "-o"])
|
||||
.arg(&template)
|
||||
.arg(source);
|
||||
run("yt-dlp", &mut cmd)?;
|
||||
let downloaded = first_file_in(work_dir)?.ok_or(MediaError::Failed {
|
||||
tool: "yt-dlp",
|
||||
message: "no media file was produced".into(),
|
||||
})?;
|
||||
ffmpeg_to_wav(&downloaded, dest_wav)
|
||||
} else {
|
||||
let input = Path::new(source);
|
||||
if !input.exists() {
|
||||
return Err(MediaError::Failed {
|
||||
tool: "import",
|
||||
message: format!("file not found: {source}"),
|
||||
});
|
||||
}
|
||||
ffmpeg_to_wav(input, dest_wav)
|
||||
}
|
||||
}
|
||||
|
||||
/// ffmpeg: any input → 16kHz mono 16-bit PCM WAV (drops video, matches the
|
||||
/// format `audio::read_wav_mono_16k` and the diarizer both expect).
|
||||
fn ffmpeg_to_wav(input: &Path, dest_wav: &Path) -> Result<(), MediaError> {
|
||||
let mut cmd = command("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(input)
|
||||
.args(["-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le"])
|
||||
.arg(dest_wav);
|
||||
run("ffmpeg", &mut cmd)
|
||||
}
|
||||
|
||||
/// The first regular file in `dir` (the single yt-dlp download; the caller uses
|
||||
/// a fresh temp dir per import so there's nothing else there).
|
||||
fn first_file_in(dir: &Path) -> Result<Option<PathBuf>, MediaError> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_file() {
|
||||
return Ok(Some(entry.path()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_url_distinguishes_urls_from_paths() {
|
||||
assert!(is_url("https://youtube.com/watch?v=abc"));
|
||||
assert!(is_url("http://example.com/a.mp4"));
|
||||
assert!(is_url(" https://leading-space.example/x")); // trimmed
|
||||
assert!(!is_url(r"C:\Users\me\meeting.mp4"));
|
||||
assert!(!is_url("/home/me/meeting.m4a"));
|
||||
assert!(!is_url("meeting.wav"));
|
||||
assert!(!is_url("ftp://not-http.example/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_reports_a_missing_local_file_without_touching_a_tool() {
|
||||
let dir = std::env::temp_dir().join(format!("wa-media-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let err = import_to_wav(
|
||||
"no-such-file.mp4",
|
||||
&dir.join("out.wav"),
|
||||
&dir,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, MediaError::Failed { tool: "import", .. }));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,19 @@ pub struct ModelInfo {
|
||||
pub size_mb: u32, // approximate download size
|
||||
pub installed: bool,
|
||||
pub active: bool,
|
||||
/// `false` for the `.en` (English-only) ggml variants; `true` for the
|
||||
/// multilingual variants (no `.en` suffix, T8.7/FR-TRX-4/M4.2) — gates
|
||||
/// whether the Settings language picker is enabled for this model.
|
||||
pub multilingual: bool,
|
||||
}
|
||||
|
||||
/// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code
|
||||
/// (as accepted by `whisper_rs::FullParams::set_language`) plus a display
|
||||
/// label for the Settings dropdown.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LanguageOption {
|
||||
pub code: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -101,6 +114,41 @@ pub struct SpeakerInfo {
|
||||
pub participant_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A user-typed note attached to a moment in the recording, anchored by
|
||||
/// timestamp rather than segment id — a segment id can be invalidated by a
|
||||
/// later batch re-transcription (T3.8), but the moment in time it pointed at
|
||||
/// never changes. `text: ""` marks a cleared note (kept rather than removed
|
||||
/// so `updated_at` still reflects the clear).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SegmentNote {
|
||||
pub anchor_ms: u64,
|
||||
pub text: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// On-disk shape of `manual_notes.json` (`docs/03-data-model.md`) — the raw
|
||||
/// user-authored input a live recording accumulates (freeform notes typed
|
||||
/// while recording, plus any per-moment annotations), kept distinct from the
|
||||
/// transcript-derived `notes.md` so a re-render never has to guess which
|
||||
/// parts of `notes.md` were hand-written.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManualNotes {
|
||||
pub schema: u32,
|
||||
pub freeform_md: String,
|
||||
pub segment_notes: Vec<SegmentNote>,
|
||||
}
|
||||
|
||||
impl Default for ManualNotes {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: 1,
|
||||
freeform_md: String::new(),
|
||||
segment_notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A diarization result span before alignment to transcript segments.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpeakerSpan {
|
||||
@@ -145,6 +193,29 @@ pub struct ActionItem {
|
||||
pub reminder_set: bool,
|
||||
}
|
||||
|
||||
/// Portable meeting export manifest — the `meeting.json` inside an export
|
||||
/// bundle folder (FR-STORE-4). Carries everything needed to reconstruct a
|
||||
/// meeting on another machine alongside the bundle's files (`audio.wav`,
|
||||
/// `transcript.json`, `notes.md`, `summary.json`). Deliberately excludes the
|
||||
/// meeting id (a fresh one is minted on import to avoid collisions) and the
|
||||
/// calendar-event link (event ids are machine-local).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingBundle {
|
||||
pub schema: u32,
|
||||
pub title: String,
|
||||
pub started_at: i64,
|
||||
pub ended_at: Option<i64>,
|
||||
pub duration_secs: Option<i64>,
|
||||
pub language: Option<String>,
|
||||
pub backend_used: Option<String>,
|
||||
pub model_used: Option<String>,
|
||||
pub recorded: bool,
|
||||
pub template_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub speakers: Vec<SpeakerInfo>,
|
||||
pub action_items: Vec<ActionItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalendarEvent {
|
||||
pub id: String,
|
||||
@@ -198,15 +269,104 @@ pub struct Settings {
|
||||
pub llm_advanced: serde_json::Value,
|
||||
pub preferred_backend: String, // auto|npu|nvidia|amd|intel|cpu
|
||||
pub whisper_model: String, // ModelInfo.id, e.g. "base.en-q5_1"
|
||||
/// Global default transcription language (T8.7, FR-TRX-4): `None`/`"auto"`
|
||||
/// lets whisper.cpp auto-detect; an ISO-639-1 code (e.g. "es") forces
|
||||
/// that language. Only takes effect with a multilingual model loaded —
|
||||
/// an English-only (`.en`) model always decodes English regardless of
|
||||
/// this setting (see `transcription::resolve_language`). Each meeting
|
||||
/// persists whatever was actually used at `Meeting.language`, so this is
|
||||
/// just the default applied at the next `start_recording`.
|
||||
#[serde(default)]
|
||||
pub whisper_language: Option<String>,
|
||||
pub low_overhead: bool,
|
||||
// Recording retention (ADR-0009). Default OFF.
|
||||
pub default_record: bool,
|
||||
pub consent_acknowledged: bool,
|
||||
/// One-time "data leaves your device" acknowledgment for hosted
|
||||
/// (non-local) AI providers — Anthropic, or a hosted OpenAI-compatible
|
||||
/// gateway (ADR-0011, T10.3/M3.3). Shown once before first hosted use;
|
||||
/// this flag is what makes it not nag every time. Independent of
|
||||
/// `consent_acknowledged` (that one's specifically about recording law).
|
||||
#[serde(default)]
|
||||
pub hosted_ai_acknowledged: bool,
|
||||
// Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain.
|
||||
pub sync_enabled: bool,
|
||||
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
|
||||
pub retention_max_age_days: Option<u32>,
|
||||
pub retention_max_size_gb: Option<u32>,
|
||||
// Calendar / .pst (T6.2) — remembered so the user doesn't re-browse every
|
||||
// launch. `pst_auto_sync` re-imports this path once at startup if set.
|
||||
#[serde(default)]
|
||||
pub pst_last_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pst_auto_sync: bool,
|
||||
/// How far back to import (days before "now"); `None` = full mailbox
|
||||
/// history (the original, unbounded behavior). Applied to both a manual
|
||||
/// Import click and the `pst_auto_sync` startup re-import — a long-lived
|
||||
/// mailbox otherwise re-imports its entire multi-year history (every
|
||||
/// recurring series expanded to its cap, every one-off holiday entry
|
||||
/// Outlook ever generated) on every launch.
|
||||
#[serde(default)]
|
||||
pub pst_import_range_days: Option<u32>,
|
||||
/// Auto-start recording when a calendar event begins while the app is open
|
||||
/// (FR-CAL, opt-in). OFF by default. No background timer runs for this: the
|
||||
/// UI arms a single one-shot timer to the next event while the app is open
|
||||
/// and disarms it on close, so idle resource use stays at zero (NFR-RES-1).
|
||||
#[serde(default)]
|
||||
pub auto_record_calendar: bool,
|
||||
// Microsoft Graph calendar source (M4.4, T8.9, ADR-0008, FR-CAL-6). Opt-in,
|
||||
// explicit consent via OAuth PKCE — off by default. The credential ref
|
||||
// points into the OS credential store; the token itself never lives here.
|
||||
#[serde(default)]
|
||||
pub graph_calendar_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub graph_calendar_credential_ref: Option<String>,
|
||||
// Audio capture device override (FR-CAP-1). `Device::get_id()` string;
|
||||
// None = system default render device (loopback / system audio).
|
||||
#[serde(default)]
|
||||
pub audio_output_device: Option<String>,
|
||||
// Microphone capture (FR-CAP-7): mix the user's own voice into the live
|
||||
// transcript. Local-only, no egress; default ON. Turn off to transcribe just
|
||||
// the system/loopback audio, as WA did before.
|
||||
#[serde(default = "default_true")]
|
||||
pub microphone_enabled: bool,
|
||||
// Microphone device override — `Device::get_id()` string; None = system
|
||||
// default capture device.
|
||||
#[serde(default)]
|
||||
pub audio_input_device: Option<String>,
|
||||
// Local MCP server (Phase 10b, ADR-0011, FR-MCP-1). OFF by default; the
|
||||
// auth token itself is NEVER stored here — only in the OS credential
|
||||
// store (see `mcp::token`). `mcp_expose` is one of none|selected|all;
|
||||
// `mcp_expose_recordings` gates access to meetings with retained audio
|
||||
// (ADR-0009) regardless of `mcp_expose` (FR-MCP-3).
|
||||
#[serde(default)]
|
||||
pub mcp_enabled: bool,
|
||||
#[serde(default = "default_mcp_transport")]
|
||||
pub mcp_transport: String, // http|stdio
|
||||
#[serde(default = "default_mcp_port")]
|
||||
pub mcp_port: u16,
|
||||
#[serde(default = "default_mcp_expose")]
|
||||
pub mcp_expose: String, // none|selected|all
|
||||
#[serde(default)]
|
||||
pub mcp_expose_recordings: bool,
|
||||
}
|
||||
|
||||
fn default_mcp_transport() -> String {
|
||||
"http".into()
|
||||
}
|
||||
|
||||
fn default_mcp_port() -> u16 {
|
||||
4849
|
||||
}
|
||||
|
||||
fn default_mcp_expose() -> String {
|
||||
"none".into()
|
||||
}
|
||||
|
||||
/// serde default for a `bool` field that should be `true` when absent from an
|
||||
/// older `settings.json` (so upgrading users get the microphone, FR-CAP-7).
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ---- Sync (ADR-0010) ----
|
||||
@@ -233,6 +393,15 @@ pub struct SyncTargetInfo {
|
||||
pub enabled: bool,
|
||||
pub third_party: bool,
|
||||
pub host: Option<String>,
|
||||
// Upload selection + options, so the UI can pre-fill an edit form (the secret
|
||||
// is never included — FR-SYNC-6).
|
||||
pub upload_transcript: bool,
|
||||
pub upload_notes: bool,
|
||||
pub upload_summary: bool,
|
||||
pub upload_recording: bool,
|
||||
pub trigger_on_finalize: bool,
|
||||
pub allow_plaintext_lan: bool,
|
||||
pub encrypt_before_upload: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+220
-44
@@ -3,7 +3,7 @@
|
||||
//! Renders speaker-tagged Markdown from transcript + speaker names (+ optional
|
||||
//! summary). Names are resolved here from the mapping; segments keep internal IDs.
|
||||
|
||||
use crate::models::{SpeakerInfo, TranscriptSegment};
|
||||
use crate::models::{ManualNotes, SegmentNote, SpeakerInfo, TranscriptSegment};
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -101,6 +101,156 @@ pub trait NotesRenderer: Send + Sync {
|
||||
|
||||
pub struct MarkdownNotes;
|
||||
|
||||
impl MarkdownNotes {
|
||||
/// Bug fix / redesign: `notes.md` used to be generated *only* from the
|
||||
/// transcript (`to_markdown`), then blindly overwritten on every
|
||||
/// finalize/speaker-rename, discarding anything the user typed. `merge`
|
||||
/// is what `stop_recording` now calls instead — it folds in whatever was
|
||||
/// captured live in `manual` (freeform notes typed during the meeting,
|
||||
/// plus any per-moment annotations) alongside the transcript, so the
|
||||
/// generated document isn't transcript-only and isn't a single
|
||||
/// same-speaker-collapsed blob (`## Notes` / `## Transcript` sections,
|
||||
/// with each annotation placed right after the paragraph it points at).
|
||||
pub fn merge(
|
||||
&self,
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
manual: &ManualNotes,
|
||||
summary_md: Option<&str>,
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
let freeform = manual.freeform_md.trim();
|
||||
if !freeform.is_empty() {
|
||||
out.push_str("## Notes\n\n");
|
||||
out.push_str(freeform);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let transcript = transcript_with_notes(segments, speakers, &manual.segment_notes);
|
||||
if !transcript.is_empty() {
|
||||
out.push_str("## Transcript\n\n");
|
||||
out.push_str(&transcript);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Template section scaffold + summary block shared by `to_markdown` and `merge`.
|
||||
fn push_prelude(out: &mut String, summary_md: Option<&str>, template: Option<&NoteTemplate>) {
|
||||
if let Some(template) = template {
|
||||
for section in &template.sections {
|
||||
out.push_str(&format!("## {section}\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(summary) = summary_md {
|
||||
let summary = summary.trim();
|
||||
if !summary.is_empty() {
|
||||
out.push_str(summary);
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups consecutive same-speaker segments into `(start_ms, end_ms,
|
||||
/// "**Name:** text")` paragraphs — the ms range is each paragraph's span in
|
||||
/// the original recording, used by `transcript_with_notes` to place a
|
||||
/// per-moment note right after the paragraph it was anchored to.
|
||||
fn dialogue_paragraphs(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
) -> Vec<(u64, u64, String)> {
|
||||
let name_for = |label: &str| -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut current_speaker: Option<&str> = None;
|
||||
let mut buffer = String::new();
|
||||
let mut range: (u64, u64) = (0, 0);
|
||||
for seg in segments {
|
||||
let text = seg.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if current_speaker != Some(seg.speaker.as_str()) {
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
current_speaker = Some(seg.speaker.as_str());
|
||||
buffer.clear();
|
||||
range = (seg.start_ms, seg.end_ms);
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
buffer.push(' ');
|
||||
}
|
||||
buffer.push_str(text);
|
||||
range.1 = seg.end_ms;
|
||||
}
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push((
|
||||
range.0,
|
||||
range.1,
|
||||
format!("**{}:** {}", name_for(speaker), buffer.trim()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The dialogue paragraphs, each followed by a `> 📝` blockquote for any
|
||||
/// `segment_notes` whose `anchor_ms` falls inside that paragraph's span.
|
||||
/// A note whose anchor doesn't land inside any paragraph (its segment fell
|
||||
/// in a gap, or vanished in a later re-transcription) still isn't dropped —
|
||||
/// it surfaces under "Other notes" at the end instead of silently
|
||||
/// disappearing.
|
||||
fn transcript_with_notes(
|
||||
segments: &[TranscriptSegment],
|
||||
speakers: &[SpeakerInfo],
|
||||
segment_notes: &[SegmentNote],
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let mut matched = vec![false; segment_notes.len()];
|
||||
for (start, end, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
for (i, note) in segment_notes.iter().enumerate() {
|
||||
let text = note.text.trim();
|
||||
if text.is_empty() || note.anchor_ms < start || note.anchor_ms > end {
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!("> 📝 {text}\n\n"));
|
||||
matched[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
let orphans: Vec<&str> = segment_notes
|
||||
.iter()
|
||||
.zip(matched.iter())
|
||||
.filter(|(n, was_matched)| !**was_matched && !n.text.trim().is_empty())
|
||||
.map(|(n, _)| n.text.trim())
|
||||
.collect();
|
||||
if !orphans.is_empty() {
|
||||
out.push_str("### Other notes\n\n");
|
||||
for text in orphans {
|
||||
out.push_str(&format!("> 📝 {text}\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
impl NotesRenderer for MarkdownNotes {
|
||||
fn to_markdown(
|
||||
&self,
|
||||
@@ -110,50 +260,11 @@ impl NotesRenderer for MarkdownNotes {
|
||||
template: Option<&NoteTemplate>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(template) = template {
|
||||
for section in &template.sections {
|
||||
out.push_str(&format!("## {section}\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(summary) = summary_md {
|
||||
let summary = summary.trim();
|
||||
if !summary.is_empty() {
|
||||
out.push_str(summary);
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
}
|
||||
push_prelude(&mut out, summary_md, template);
|
||||
|
||||
let name_for = |label: &str| -> String {
|
||||
speakers
|
||||
.iter()
|
||||
.find(|s| s.label == label)
|
||||
.and_then(|s| s.display_name.clone())
|
||||
.unwrap_or_else(|| label.to_string())
|
||||
};
|
||||
|
||||
// Group consecutive segments from the same speaker into one paragraph
|
||||
// (matters once Phase 4 diarization produces more than one speaker).
|
||||
let mut current_speaker: Option<&str> = None;
|
||||
let mut buffer = String::new();
|
||||
for seg in segments {
|
||||
let text = seg.text.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if current_speaker != Some(seg.speaker.as_str()) {
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
}
|
||||
current_speaker = Some(seg.speaker.as_str());
|
||||
buffer.clear();
|
||||
}
|
||||
if !buffer.is_empty() {
|
||||
buffer.push(' ');
|
||||
}
|
||||
buffer.push_str(text);
|
||||
}
|
||||
if let Some(speaker) = current_speaker {
|
||||
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||
for (_, _, paragraph) in dialogue_paragraphs(segments, speakers) {
|
||||
out.push_str(¶graph);
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
out.trim_end().to_string()
|
||||
@@ -350,6 +461,71 @@ mod tests {
|
||||
assert_eq!(md, "## Discussion\n\n## Action Items\n\n**S1:** Hi");
|
||||
}
|
||||
|
||||
fn note(anchor_ms: u64, text: &str) -> SegmentNote {
|
||||
SegmentNote {
|
||||
anchor_ms,
|
||||
text: text.to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_no_manual_notes_matches_to_markdown() {
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_adds_a_notes_section_for_freeform_text() {
|
||||
let segments = vec![seg(0, "S1", "Hello")];
|
||||
let manual = ManualNotes {
|
||||
freeform_md: "Remember to follow up.".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Notes\n\nRemember to follow up.\n\n## Transcript\n\n**S1:** Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_places_a_segment_note_right_after_its_paragraph() {
|
||||
// seg 0 spans 0-900ms ("S1"); anchor 500 falls inside it.
|
||||
let segments = vec![seg(0, "S1", "Hello"), seg(1, "S2", "Hi there.")];
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(500, "circle back on this")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n> \u{1f4dd} circle back on this\n\n**S2:** Hi there."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_surfaces_an_unmatched_anchor_under_other_notes() {
|
||||
let segments = vec![seg(0, "S1", "Hello")]; // spans 0-900ms
|
||||
let manual = ManualNotes {
|
||||
segment_notes: vec![note(50_000, "way outside any paragraph")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
MarkdownNotes.merge(&segments, &[], &manual, None, None),
|
||||
"## Transcript\n\n**S1:** Hello\n\n### Other notes\n\n> \u{1f4dd} way outside any paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_omits_empty_sections_for_an_empty_meeting() {
|
||||
let manual = ManualNotes::default();
|
||||
assert_eq!(MarkdownNotes.merge(&[], &[], &manual, None, None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() {
|
||||
let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing";
|
||||
|
||||
@@ -29,6 +29,13 @@ pub fn meeting_dir(id: &MeetingId) -> PathBuf {
|
||||
meetings_dir().join(id)
|
||||
}
|
||||
|
||||
/// Raw user-authored notes (freeform + per-moment annotations) accumulated
|
||||
/// live during a recording — see `models::ManualNotes`. Distinct from the
|
||||
/// derived `notes.md` the same directory holds after finalize.
|
||||
pub fn manual_notes_file(id: &MeetingId) -> PathBuf {
|
||||
meeting_dir(id).join("manual_notes.json")
|
||||
}
|
||||
|
||||
pub fn models_dir() -> PathBuf {
|
||||
wa_root().join("models")
|
||||
}
|
||||
|
||||
+905
-57
File diff suppressed because it is too large
Load Diff
+2145
-37
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,15 @@ pub fn provider_for(kind: &str) -> Option<OAuthProvider> {
|
||||
scopes: &["root_readwrite"],
|
||||
client_id_env: "WA_OAUTH_BOX_CLIENT_ID",
|
||||
}),
|
||||
// Same identity platform as "onedrive", read-only calendar scope only
|
||||
// (M4.4, T8.9, FR-CAL-6) — WA never requests file/mail access here.
|
||||
"graph-calendar" => Some(OAuthProvider {
|
||||
kind: "graph-calendar",
|
||||
auth_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
token_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
scopes: &["offline_access", "Calendars.Read", "User.Read"],
|
||||
client_id_env: "WA_OAUTH_GRAPH_CALENDAR_CLIENT_ID",
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Selectable transcription languages (T8.7, FR-TRX-4, M4.2) — the Settings
|
||||
//! language dropdown shown when a multilingual model is active.
|
||||
//!
|
||||
//! ponytail: a fixed table, not a runtime query against whisper.cpp's
|
||||
//! `whisper_lang_str`/`whisper_lang_max_id` — the ~100-language set whisper.cpp
|
||||
//! ships is effectively static (OpenAI's Whisper `tokenizer.py` LANGUAGES
|
||||
//! table), and hardcoding it here means the list is available to the UI even
|
||||
//! before any model is loaded (no `cpu-transcription` feature dependency).
|
||||
|
||||
use crate::models::LanguageOption;
|
||||
|
||||
/// `(ISO-639-1 code, display label)`, exactly the codes whisper.cpp accepts
|
||||
/// via `whisper_full_params.language`.
|
||||
const LANGUAGES: &[(&str, &str)] = &[
|
||||
("en", "English"),
|
||||
("zh", "Chinese"),
|
||||
("de", "German"),
|
||||
("es", "Spanish"),
|
||||
("ru", "Russian"),
|
||||
("ko", "Korean"),
|
||||
("fr", "French"),
|
||||
("ja", "Japanese"),
|
||||
("pt", "Portuguese"),
|
||||
("tr", "Turkish"),
|
||||
("pl", "Polish"),
|
||||
("ca", "Catalan"),
|
||||
("nl", "Dutch"),
|
||||
("ar", "Arabic"),
|
||||
("sv", "Swedish"),
|
||||
("it", "Italian"),
|
||||
("id", "Indonesian"),
|
||||
("hi", "Hindi"),
|
||||
("fi", "Finnish"),
|
||||
("vi", "Vietnamese"),
|
||||
("he", "Hebrew"),
|
||||
("uk", "Ukrainian"),
|
||||
("el", "Greek"),
|
||||
("ms", "Malay"),
|
||||
("cs", "Czech"),
|
||||
("ro", "Romanian"),
|
||||
("da", "Danish"),
|
||||
("hu", "Hungarian"),
|
||||
("ta", "Tamil"),
|
||||
("no", "Norwegian"),
|
||||
("th", "Thai"),
|
||||
("ur", "Urdu"),
|
||||
("hr", "Croatian"),
|
||||
("bg", "Bulgarian"),
|
||||
("lt", "Lithuanian"),
|
||||
("la", "Latin"),
|
||||
("mi", "Maori"),
|
||||
("ml", "Malayalam"),
|
||||
("cy", "Welsh"),
|
||||
("sk", "Slovak"),
|
||||
("te", "Telugu"),
|
||||
("fa", "Persian"),
|
||||
("lv", "Latvian"),
|
||||
("bn", "Bengali"),
|
||||
("sr", "Serbian"),
|
||||
("az", "Azerbaijani"),
|
||||
("sl", "Slovenian"),
|
||||
("kn", "Kannada"),
|
||||
("et", "Estonian"),
|
||||
("mk", "Macedonian"),
|
||||
("br", "Breton"),
|
||||
("eu", "Basque"),
|
||||
("is", "Icelandic"),
|
||||
("hy", "Armenian"),
|
||||
("ne", "Nepali"),
|
||||
("mn", "Mongolian"),
|
||||
("bs", "Bosnian"),
|
||||
("kk", "Kazakh"),
|
||||
("sq", "Albanian"),
|
||||
("sw", "Swahili"),
|
||||
("gl", "Galician"),
|
||||
("mr", "Marathi"),
|
||||
("pa", "Punjabi"),
|
||||
("si", "Sinhala"),
|
||||
("km", "Khmer"),
|
||||
("sn", "Shona"),
|
||||
("yo", "Yoruba"),
|
||||
("so", "Somali"),
|
||||
("af", "Afrikaans"),
|
||||
("oc", "Occitan"),
|
||||
("ka", "Georgian"),
|
||||
("be", "Belarusian"),
|
||||
("tg", "Tajik"),
|
||||
("sd", "Sindhi"),
|
||||
("gu", "Gujarati"),
|
||||
("am", "Amharic"),
|
||||
("yi", "Yiddish"),
|
||||
("lo", "Lao"),
|
||||
("uz", "Uzbek"),
|
||||
("fo", "Faroese"),
|
||||
("ht", "Haitian Creole"),
|
||||
("ps", "Pashto"),
|
||||
("tk", "Turkmen"),
|
||||
("nn", "Nynorsk"),
|
||||
("mt", "Maltese"),
|
||||
("sa", "Sanskrit"),
|
||||
("lb", "Luxembourgish"),
|
||||
("my", "Myanmar"),
|
||||
("bo", "Tibetan"),
|
||||
("tl", "Tagalog"),
|
||||
("mg", "Malagasy"),
|
||||
("as", "Assamese"),
|
||||
("tt", "Tatar"),
|
||||
("haw", "Hawaiian"),
|
||||
("ln", "Lingala"),
|
||||
("ha", "Hausa"),
|
||||
("ba", "Bashkir"),
|
||||
("jw", "Javanese"),
|
||||
("su", "Sundanese"),
|
||||
("yue", "Cantonese"),
|
||||
];
|
||||
|
||||
/// The dropdown's contents (`list_whisper_languages` command) — "Auto-detect"
|
||||
/// itself is not in this list; the frontend prepends it (maps to `None`/no
|
||||
/// `language` argument).
|
||||
pub fn list() -> Vec<LanguageOption> {
|
||||
LANGUAGES
|
||||
.iter()
|
||||
.map(|(code, label)| LanguageOption {
|
||||
code: code.to_string(),
|
||||
label: label.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `code` is a known whisper.cpp language code (case-insensitive).
|
||||
/// Used to validate an explicit selection before it's forced into
|
||||
/// `FullParams::set_language` — an unrecognized code is still passed through
|
||||
/// to whisper.cpp (it may support codes we haven't listed), but callers use
|
||||
/// this to warn rather than silently accept a typo.
|
||||
pub fn is_known(code: &str) -> bool {
|
||||
LANGUAGES.iter().any(|(c, _)| c.eq_ignore_ascii_case(code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_includes_english_and_common_languages() {
|
||||
let langs = list();
|
||||
assert!(langs.iter().any(|l| l.code == "en" && l.label == "English"));
|
||||
assert!(langs.iter().any(|l| l.code == "es"));
|
||||
assert!(langs.iter().any(|l| l.code == "fr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_are_unique() {
|
||||
let mut codes: Vec<&str> = LANGUAGES.iter().map(|(c, _)| *c).collect();
|
||||
let before = codes.len();
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
assert_eq!(before, codes.len(), "duplicate language code in catalog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_known_is_case_insensitive() {
|
||||
assert!(is_known("en"));
|
||||
assert!(is_known("ES"));
|
||||
assert!(!is_known("xx-not-a-real-code"));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::models::{BackendId, TranscriptSegment};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
pub mod languages;
|
||||
pub mod models;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -29,7 +30,13 @@ pub struct AudioWindow {
|
||||
pub type SegmentSink = Sender<TranscriptSegment>;
|
||||
|
||||
pub trait Transcriber: Send + Sync {
|
||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError>
|
||||
/// `language` (T8.7, FR-TRX-4): `None` or `Some("auto")` requests
|
||||
/// auto-detection; an explicit ISO-639-1 code (e.g. `"es"`) forces that
|
||||
/// language. Engines that can't honor a request (an English-only model,
|
||||
/// or an engine with no language selection at all, like the NPU/ONNX
|
||||
/// path) resolve it against their own capability at load time rather
|
||||
/// than erroring — see `resolve_language` and `effective_language`.
|
||||
fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result<Self, TrxError>
|
||||
where
|
||||
Self: Sized;
|
||||
/// Streaming: emit interim + final segments for a window (FR-TRX-2).
|
||||
@@ -37,6 +44,58 @@ pub trait Transcriber: Send + Sync {
|
||||
/// Batch: one-shot over a whole file, higher accuracy — also the crash-recovery
|
||||
/// path (FR-TRX-3, T2.8): re-run over the working `audio.wav` from scratch.
|
||||
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>;
|
||||
|
||||
/// The language this engine is actually configured to decode, resolved
|
||||
/// against model capability at `load` time — `None` means auto-detect.
|
||||
/// Default: engines with no language selection of their own (the
|
||||
/// NPU/ONNX path, whose ONNX artifacts are exported English-only) always
|
||||
/// decode English.
|
||||
fn effective_language(&self) -> Option<String> {
|
||||
Some("en".to_string())
|
||||
}
|
||||
|
||||
/// The language actually used/detected on the most recent decode, if the
|
||||
/// engine surfaces one (whisper.cpp does, via `full_lang_id_from_state`).
|
||||
/// `None` until at least one decode has completed, or if the engine
|
||||
/// doesn't support detection reporting at all.
|
||||
fn detected_language(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a requested language against model capability: an English-only
|
||||
/// (`.en`) whisper.cpp model can only ever decode English, so an explicit
|
||||
/// non-English request is forced to `"en"` (with a warning) rather than
|
||||
/// being silently honored into a garbage transcript — the T8.7/FR-TRX-4
|
||||
/// "no silent footgun" requirement. `None`/`"auto"` always means
|
||||
/// auto-detect, regardless of model, since that's harmless either way
|
||||
/// (whisper.cpp itself forces English internally for a non-multilingual
|
||||
/// model even when `language` is unset).
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
fn resolve_language(requested: Option<&str>, multilingual: bool) -> Option<String> {
|
||||
match requested {
|
||||
None => None,
|
||||
Some(l) if l.eq_ignore_ascii_case("auto") => None,
|
||||
Some(l) if l.eq_ignore_ascii_case("en") => Some("en".to_string()),
|
||||
Some(l) if !multilingual => {
|
||||
tracing::warn!(
|
||||
"language '{l}' requested but the loaded model is English-only; forcing 'en'"
|
||||
);
|
||||
Some("en".to_string())
|
||||
}
|
||||
Some(l) => Some(l.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments whisper.cpp itself flags as more likely silence than speech are
|
||||
/// dropped rather than emitted (see `run_full`) — whisper.cpp's CLI ships
|
||||
/// this same 0.6 default for `--no-speech-thold`.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
const NO_SPEECH_THRESHOLD: f32 = 0.6;
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
fn is_likely_speech(no_speech_probability: f32) -> bool {
|
||||
no_speech_probability <= NO_SPEECH_THRESHOLD
|
||||
}
|
||||
|
||||
/// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features).
|
||||
@@ -44,6 +103,14 @@ pub trait Transcriber: Send + Sync {
|
||||
pub struct WhisperTranscriber {
|
||||
ctx: whisper_rs::WhisperContext,
|
||||
next_id: std::sync::atomic::AtomicU64,
|
||||
/// What gets passed to `FullParams::set_language` on every decode —
|
||||
/// resolved once at `load` (see `resolve_language`), not re-resolved per
|
||||
/// window/file, since a whole recording session uses one language.
|
||||
configured_language: Option<String>,
|
||||
/// The language whisper.cpp actually used on the most recent `full()`
|
||||
/// call (`whisper_full_lang_id_from_state`), updated after every decode
|
||||
/// so "auto" mode has something concrete to persist (T8.7, FR-TRX-4).
|
||||
last_detected_language: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
@@ -85,6 +152,9 @@ impl WhisperTranscriber {
|
||||
params.set_print_timestamps(false);
|
||||
params.set_suppress_blank(true);
|
||||
params.set_single_segment(single_segment);
|
||||
// T8.7/FR-TRX-4: `None` here means auto-detect, matching
|
||||
// `configured_language`'s resolved meaning (see `resolve_language`).
|
||||
params.set_language(self.configured_language.as_deref());
|
||||
|
||||
if single_segment {
|
||||
// whisper.cpp's encoder always runs over a full, padded 30s mel
|
||||
@@ -100,6 +170,18 @@ impl WhisperTranscriber {
|
||||
.full(params, samples)
|
||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||
|
||||
// T8.7/FR-TRX-4: record whatever language whisper.cpp actually used
|
||||
// for this decode (explicit request or auto-detected) so "auto" mode
|
||||
// has a concrete value to persist per meeting. Best-effort — an
|
||||
// unrecognized lang id (or a poisoned mutex) just leaves the last
|
||||
// known value in place rather than failing the transcription.
|
||||
let lang_id = state.full_lang_id_from_state();
|
||||
if let Some(code) = whisper_rs::get_lang_str(lang_id) {
|
||||
if let Ok(mut last) = self.last_detected_language.lock() {
|
||||
*last = Some(code.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// A forced single_segment's reported end_timestamp() reflects
|
||||
// whisper.cpp's internal 30s-padded mel frame, not the real window
|
||||
// length — confirmed even with `duration_ms` set, so don't trust it.
|
||||
@@ -114,6 +196,15 @@ impl WhisperTranscriber {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// whisper.cpp's own `no_speech_thold` gate is a no-op (unimplemented
|
||||
// upstream as of whisper-rs 0.16 / whisper.cpp v1.3.0+), so silent/
|
||||
// near-silent windows still decode — and greedy short-window decode
|
||||
// reliably hallucinates a short filler word ("you", "Thank you.")
|
||||
// instead of emitting nothing. Drop those ourselves: matches
|
||||
// whisper.cpp's own CLI default threshold for "this was silence".
|
||||
if !is_likely_speech(seg.no_speech_probability()) {
|
||||
continue;
|
||||
}
|
||||
// Whisper timestamps are centiseconds (10ms units).
|
||||
let start_ms = offset_ms + seg.start_timestamp().max(0) as u64 * 10;
|
||||
let end_ms = if single_segment {
|
||||
@@ -145,16 +236,19 @@ impl Transcriber for WhisperTranscriber {
|
||||
/// so `use_gpu` on a CPU-only build is a harmless no-op — this stays a
|
||||
/// single code path either way rather than branching on which features
|
||||
/// were compiled in.
|
||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError> {
|
||||
fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result<Self, TrxError> {
|
||||
let params = whisper_rs::WhisperContextParameters {
|
||||
use_gpu: !matches!(backend, BackendId::Cpu),
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = whisper_rs::WhisperContext::new_with_params(model, params)
|
||||
.map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
let configured_language = resolve_language(language, ctx.is_multilingual());
|
||||
Ok(Self {
|
||||
ctx,
|
||||
next_id: std::sync::atomic::AtomicU64::new(0),
|
||||
configured_language,
|
||||
last_detected_language: std::sync::Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,6 +272,17 @@ impl Transcriber for WhisperTranscriber {
|
||||
crate::audio::read_wav_mono_16k(wav).map_err(|e| TrxError::Load(e.to_string()))?;
|
||||
self.run_full(&samples, 0, false)
|
||||
}
|
||||
|
||||
fn effective_language(&self) -> Option<String> {
|
||||
self.configured_language.clone()
|
||||
}
|
||||
|
||||
fn detected_language(&self) -> Option<String> {
|
||||
self.last_detected_language
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
@@ -212,63 +317,215 @@ pub mod onnx_models;
|
||||
#[cfg(feature = "npu")]
|
||||
pub use npu::OnnxTranscriber;
|
||||
|
||||
/// Streaming window worker (Phase 1, T1.5/T1.6): accumulates raw 16kHz-mono
|
||||
/// chunks from the `audio` service into fixed-size, **non-overlapping** windows
|
||||
/// and runs one `transcribe_stream` pass per window as it fills, forwarding
|
||||
/// each produced segment to `on_segment` (e.g. a Tauri event emit).
|
||||
const STREAM_SAMPLE_RATE: usize = 16_000;
|
||||
|
||||
/// Cadence/length knobs for the live streaming worker (`Streamer`). Tuned for a
|
||||
/// fluid transcript that shows words ~1s after they're spoken and breaks lines
|
||||
/// at natural pauses rather than on a fixed clock.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StreamTuning {
|
||||
/// How often the growing window is re-decoded (and the interim line
|
||||
/// refreshed). Lower = more responsive, but more CPU (each decode re-runs
|
||||
/// whisper over the whole in-progress window).
|
||||
pub step_ms: u64,
|
||||
/// Hard cap on an uncommitted window: once the current line reaches this
|
||||
/// without a natural pause, it's force-committed so the window (and its
|
||||
/// per-step decode cost) can't grow without bound.
|
||||
pub max_window_ms: u64,
|
||||
/// Don't commit a line shorter than this on a detected pause — avoids
|
||||
/// chopping a brief hesitation into its own one-word line.
|
||||
pub min_commit_ms: u64,
|
||||
}
|
||||
|
||||
impl StreamTuning {
|
||||
/// `low_overhead` doubles the decode step (halving CPU) at the cost of a
|
||||
/// slightly less immediate transcript — matches the Settings "low overhead"
|
||||
/// preset (CPU + smallest model, for battery/background use).
|
||||
pub fn new(low_overhead: bool) -> Self {
|
||||
Self {
|
||||
step_ms: if low_overhead { 2000 } else { 1000 },
|
||||
max_window_ms: 12_000,
|
||||
min_commit_ms: if low_overhead { 2000 } else { 1500 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamTuning {
|
||||
fn default() -> Self {
|
||||
Self::new(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental live-transcript state machine, independent of any transcription
|
||||
/// engine (a `decode: &[f32] -> String` closure is injected) so its
|
||||
/// commit/interim logic is unit-testable without whisper.
|
||||
///
|
||||
/// `transcribe_stream` takes a `SegmentSink` per the `Transcriber` trait (so a
|
||||
/// future async/threaded engine can push mid-inference), but whisper.cpp's
|
||||
/// `full()` call is synchronous — by the time a window's `transcribe_stream`
|
||||
/// call returns, every segment it produced is already sitting in a fresh
|
||||
/// per-window channel, so this drains it inline rather than needing a second
|
||||
/// long-lived thread just to bridge segments out.
|
||||
/// The model: keep one growing "uncommitted" window of audio. Every `step_ms`,
|
||||
/// re-decode the whole window and emit it as an **interim** segment with a
|
||||
/// stable id — so the current line grows in place (fluid, low-latency) instead
|
||||
/// of popping in whole every few seconds. When the decoded text stops changing
|
||||
/// for a step (the speaker paused, so the extra audio was silence) the line is
|
||||
/// **committed** (`interim = false`, same id) and a fresh window/line begins —
|
||||
/// so lines break at natural sentence pauses, not on a fixed 4s clock. A
|
||||
/// `max_window_ms` backstop force-commits a pause-free monologue.
|
||||
///
|
||||
/// True incremental/partial-word streaming (and window overlap for continuity)
|
||||
/// are out of scope for Phase 1: whisper.cpp transcribes each window from
|
||||
/// scratch, so an overlapping window would re-emit the overlapped words a
|
||||
/// second time with no stitching logic to merge them — a worse rough edge for
|
||||
/// a live transcript than the occasional word clipped at a window boundary.
|
||||
/// "Near real time" (FR-TRX-2) is met by short (~4s) windows; `interim` stays
|
||||
/// `false` for every segment produced here.
|
||||
/// whisper.cpp isn't a true streaming recognizer (it re-decodes from scratch),
|
||||
/// so this trades CPU — the growing window is re-decoded every step — for a
|
||||
/// natural-looking transcript. `audio_ctx_for_window` keeps each decode's cost
|
||||
/// proportional to the window length rather than a full 30s encode, and
|
||||
/// committing on pauses keeps the window short for conversational speech.
|
||||
pub struct Streamer {
|
||||
step_len: usize,
|
||||
max_len: usize,
|
||||
min_commit_len: usize,
|
||||
window: Vec<f32>,
|
||||
committed_offset_ms: u64,
|
||||
since_last_decode: usize,
|
||||
next_id: u64,
|
||||
last_text: String,
|
||||
}
|
||||
|
||||
impl Streamer {
|
||||
pub fn new(tuning: StreamTuning) -> Self {
|
||||
let per_ms = STREAM_SAMPLE_RATE / 1000;
|
||||
Self {
|
||||
step_len: tuning.step_ms as usize * per_ms,
|
||||
max_len: tuning.max_window_ms as usize * per_ms,
|
||||
min_commit_len: tuning.min_commit_ms as usize * per_ms,
|
||||
window: Vec::new(),
|
||||
committed_offset_ms: 0,
|
||||
since_last_decode: 0,
|
||||
next_id: 0,
|
||||
last_text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append newly captured audio; decode + emit once a step's worth has
|
||||
/// accumulated. `decode(samples, offset_ms)` returns the transcript of the
|
||||
/// window so far (empty for silence).
|
||||
pub fn feed<D, F>(&mut self, chunk: &[f32], decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.window.extend_from_slice(chunk);
|
||||
self.since_last_decode += chunk.len();
|
||||
if self.since_last_decode >= self.step_len {
|
||||
self.since_last_decode = 0;
|
||||
self.tick(false, decode, on_segment);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit whatever's in flight — called once when capture stops so the last
|
||||
/// in-progress line is finalized rather than left interim.
|
||||
pub fn flush<D, F>(&mut self, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
self.tick(true, decode, on_segment);
|
||||
}
|
||||
|
||||
fn tick<D, F>(&mut self, force: bool, decode: &D, on_segment: &mut F)
|
||||
where
|
||||
D: Fn(&[f32], u64) -> String,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
if self.window.is_empty() {
|
||||
return;
|
||||
}
|
||||
let window_ms = (self.window.len() as u64 * 1000) / STREAM_SAMPLE_RATE as u64;
|
||||
let text = decode(&self.window, self.committed_offset_ms);
|
||||
let over_max = self.window.len() >= self.max_len;
|
||||
|
||||
// Nothing recognized yet (leading/standalone silence): don't show an
|
||||
// empty line, but still drop the buffer once it's grown too big so we
|
||||
// aren't re-decoding a long silence every step.
|
||||
if text.is_empty() {
|
||||
if over_max || force {
|
||||
self.reset(window_ms, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let stable = text == self.last_text;
|
||||
let commit = force || over_max || (stable && self.window.len() >= self.min_commit_len);
|
||||
on_segment(TranscriptSegment {
|
||||
id: self.next_id,
|
||||
start_ms: self.committed_offset_ms,
|
||||
end_ms: self.committed_offset_ms + window_ms,
|
||||
// Provisional speaker; the post-stop diarization pass reassigns.
|
||||
speaker: "S1".to_string(),
|
||||
text: text.clone(),
|
||||
confidence: None,
|
||||
interim: !commit,
|
||||
});
|
||||
if commit {
|
||||
self.reset(window_ms, true);
|
||||
} else {
|
||||
self.last_text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a fresh window/line after a commit (`new_line`) or after dropping
|
||||
/// leading silence (`!new_line`, which reuses the id since nothing was
|
||||
/// emitted for it).
|
||||
fn reset(&mut self, window_ms: u64, new_line: bool) {
|
||||
self.committed_offset_ms += window_ms;
|
||||
self.window.clear();
|
||||
self.last_text.clear();
|
||||
self.since_last_decode = 0;
|
||||
if new_line {
|
||||
self.next_id += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live streaming worker (FR-TRX-2): drives a `Streamer` from the `audio`
|
||||
/// service's 16kHz-mono frames, decoding each growing window with `transcriber`
|
||||
/// and forwarding interim-then-final segments to `on_segment` (a Tauri emit).
|
||||
///
|
||||
/// `transcribe_stream` is synchronous for whisper.cpp — by the time it returns,
|
||||
/// every segment it produced is already in the per-call channel — so this
|
||||
/// drains it inline and joins the text into the window's transcript for the
|
||||
/// `Streamer` to diff/commit.
|
||||
#[cfg(feature = "cpu-transcription")]
|
||||
pub fn run_streaming_worker<T, F>(transcriber: &T, frame_rx: Receiver<Vec<f32>>, mut on_segment: F)
|
||||
where
|
||||
pub fn run_streaming_worker<T, F>(
|
||||
transcriber: &T,
|
||||
frame_rx: Receiver<Vec<f32>>,
|
||||
tuning: StreamTuning,
|
||||
mut on_segment: F,
|
||||
) where
|
||||
// `?Sized` so the dispatcher can hand us a `&dyn Transcriber` (whisper.cpp
|
||||
// or the NPU engine, chosen at runtime) rather than a concrete type.
|
||||
T: Transcriber + ?Sized,
|
||||
F: FnMut(TranscriptSegment),
|
||||
{
|
||||
const SAMPLE_RATE: usize = 16_000;
|
||||
const WINDOW_SECS: f32 = 4.0;
|
||||
let window_len = (WINDOW_SECS * SAMPLE_RATE as f32) as usize;
|
||||
|
||||
let mut buf: Vec<f32> = Vec::new();
|
||||
let mut offset_ms: u64 = 0;
|
||||
|
||||
let run_window = |transcriber: &T, samples: Vec<f32>, offset_ms: u64, on_segment: &mut F| {
|
||||
let decode = |samples: &[f32], offset_ms: u64| -> String {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let window = AudioWindow { samples, offset_ms };
|
||||
let window = AudioWindow {
|
||||
samples: samples.to_vec(),
|
||||
offset_ms,
|
||||
};
|
||||
if let Err(e) = transcriber.transcribe_stream(window, tx) {
|
||||
tracing::warn!("transcription window failed: {e}");
|
||||
return String::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
while let Ok(segment) = rx.try_recv() {
|
||||
on_segment(segment);
|
||||
let t = segment.text.trim();
|
||||
if !t.is_empty() {
|
||||
parts.push(t.to_string());
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
};
|
||||
|
||||
let mut streamer = Streamer::new(tuning);
|
||||
while let Ok(chunk) = frame_rx.recv() {
|
||||
buf.extend_from_slice(&chunk);
|
||||
while buf.len() >= window_len {
|
||||
let samples: Vec<f32> = buf.drain(..window_len).collect();
|
||||
run_window(transcriber, samples, offset_ms, &mut on_segment);
|
||||
offset_ms += (window_len as u64 * 1000) / SAMPLE_RATE as u64;
|
||||
}
|
||||
}
|
||||
// Final partial window on stop, if there's enough audio to be worth a pass.
|
||||
if buf.len() > SAMPLE_RATE / 2 {
|
||||
run_window(transcriber, buf, offset_ms, &mut on_segment);
|
||||
streamer.feed(&chunk, &decode, &mut on_segment);
|
||||
}
|
||||
streamer.flush(&decode, &mut on_segment);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -276,6 +533,113 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Collect a step's emitted segments into `out` — the pushing closure lives
|
||||
// and dies inside the call, so `out` is free to read in the asserts after
|
||||
// (a single long-lived `on` closure would keep `out` mutably borrowed).
|
||||
fn feed_into<D: Fn(&[f32], u64) -> String>(
|
||||
s: &mut Streamer,
|
||||
chunk: &[f32],
|
||||
decode: &D,
|
||||
out: &mut Vec<TranscriptSegment>,
|
||||
) {
|
||||
s.feed(chunk, decode, &mut |seg| out.push(seg));
|
||||
}
|
||||
|
||||
fn flush_into<D: Fn(&[f32], u64) -> String>(
|
||||
s: &mut Streamer,
|
||||
decode: &D,
|
||||
out: &mut Vec<TranscriptSegment>,
|
||||
) {
|
||||
s.flush(decode, &mut |seg| out.push(seg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_grows_interim_then_commits_on_a_pause() {
|
||||
// Text grows for two steps, then repeats (the speaker paused, so the
|
||||
// extra second was silence) → the line commits.
|
||||
let tuning = StreamTuning {
|
||||
step_ms: 1000,
|
||||
max_window_ms: 12_000,
|
||||
min_commit_ms: 1500,
|
||||
};
|
||||
let mut s = Streamer::new(tuning);
|
||||
let script = std::cell::Cell::new(0usize);
|
||||
let texts = ["hello", "hello world", "hello world"];
|
||||
let decode = |_: &[f32], _off: u64| -> String {
|
||||
let i = script.get();
|
||||
script.set(i + 1);
|
||||
texts.get(i).copied().unwrap_or("hello world").to_string()
|
||||
};
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000]; // exactly one 1s step
|
||||
|
||||
feed_into(&mut s, &step, &decode, &mut out); // "hello" — interim
|
||||
feed_into(&mut s, &step, &decode, &mut out); // "hello world" — interim
|
||||
feed_into(&mut s, &step, &decode, &mut out); // stable + past min_commit → commit
|
||||
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out[0].interim && out[0].text == "hello");
|
||||
assert!(out[1].interim && out[1].text == "hello world");
|
||||
assert!(!out[2].interim && out[2].text == "hello world");
|
||||
assert_eq!(out[0].id, out[2].id, "same line id until it commits");
|
||||
|
||||
// A new line after the commit uses a fresh id and a later offset.
|
||||
let d2 = |_: &[f32], _o: u64| "next sentence".to_string();
|
||||
feed_into(&mut s, &step, &d2, &mut out);
|
||||
assert!(out[3].interim && out[3].text == "next sentence");
|
||||
assert_ne!(out[3].id, out[2].id);
|
||||
assert!(out[3].start_ms >= 3000, "starts after the 3 committed steps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_shows_no_line_for_pure_silence() {
|
||||
let mut s = Streamer::new(StreamTuning::new(false));
|
||||
let decode = |_: &[f32], _o: u64| String::new();
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000];
|
||||
for _ in 0..5 {
|
||||
feed_into(&mut s, &step, &decode, &mut out);
|
||||
}
|
||||
assert!(out.is_empty(), "silence must not emit an empty line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_flush_commits_the_in_progress_line() {
|
||||
let mut s = Streamer::new(StreamTuning::new(false));
|
||||
let decode = |_: &[f32], _o: u64| "partial".to_string();
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
feed_into(&mut s, &vec![0.0f32; 16_000], &decode, &mut out);
|
||||
assert!(out.last().unwrap().interim, "still growing before flush");
|
||||
flush_into(&mut s, &decode, &mut out);
|
||||
let last = out.last().unwrap();
|
||||
assert!(!last.interim && last.text == "partial", "flush finalizes it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamer_force_commits_a_pause_free_monologue_at_max() {
|
||||
// Text never repeats (continuous speech), so only the max-window
|
||||
// backstop can commit it — otherwise the window (and decode cost) grows
|
||||
// without bound.
|
||||
let tuning = StreamTuning {
|
||||
step_ms: 1000,
|
||||
max_window_ms: 3000,
|
||||
min_commit_ms: 1500,
|
||||
};
|
||||
let mut s = Streamer::new(tuning);
|
||||
let n = std::cell::Cell::new(0usize);
|
||||
let decode = |_: &[f32], _o: u64| {
|
||||
let i = n.get();
|
||||
n.set(i + 1);
|
||||
format!("word{i}")
|
||||
};
|
||||
let mut out: Vec<TranscriptSegment> = Vec::new();
|
||||
let step = vec![0.0f32; 16_000];
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 1s
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 2s
|
||||
feed_into(&mut s, &step, &decode, &mut out); // 3s == max → force commit
|
||||
assert!(!out.last().unwrap().interim);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_ctx_scales_proportionally_to_window_length() {
|
||||
// The actual streaming WINDOW_SECS (4.0) -> ~200 (201 after `.ceil()`
|
||||
@@ -298,6 +662,17 @@ mod tests {
|
||||
assert_eq!(audio_ctx_for_window(60 * 16_000), 1500); // 60s window
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_likely_speech_drops_high_no_speech_probability_windows() {
|
||||
// Silence/near-silence: whisper.cpp's own greedy hallucination case
|
||||
// ("you", "Thank you.") on an otherwise-quiet window.
|
||||
assert!(!is_likely_speech(0.9));
|
||||
assert!(!is_likely_speech(NO_SPEECH_THRESHOLD + 0.01));
|
||||
// Confident speech kept, including right at the threshold.
|
||||
assert!(is_likely_speech(0.0));
|
||||
assert!(is_likely_speech(NO_SPEECH_THRESHOLD));
|
||||
}
|
||||
|
||||
/// GPU spike (opt-in): times whisper.cpp on a chosen backend against a real
|
||||
/// model + wav. With `--features vulkan` and `WA_BACKEND=intel` (or nvidia/amd)
|
||||
/// whisper.cpp offloads to the GPU; `WA_BACKEND=cpu` is the baseline. Run:
|
||||
@@ -315,7 +690,7 @@ mod tests {
|
||||
_ => BackendId::Intel, // use_gpu = true for any non-CPU backend
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let transcriber = WhisperTranscriber::load(Path::new(&model), backend).expect("load");
|
||||
let transcriber = WhisperTranscriber::load(Path::new(&model), backend, None).expect("load");
|
||||
let load_ms = t0.elapsed().as_millis();
|
||||
let t1 = std::time::Instant::now();
|
||||
let segments = transcriber
|
||||
@@ -330,4 +705,95 @@ mod tests {
|
||||
eprintln!("[spike] backend={backend:?} load={load_ms}ms infer={infer_ms}ms text={text:?}");
|
||||
assert!(!text.trim().is_empty(), "transcript was empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_language_auto_and_none_both_mean_auto_detect() {
|
||||
assert_eq!(resolve_language(None, true), None);
|
||||
assert_eq!(resolve_language(Some("auto"), true), None);
|
||||
assert_eq!(resolve_language(Some("AUTO"), true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_language_explicit_on_multilingual_model_passes_through() {
|
||||
assert_eq!(resolve_language(Some("es"), true), Some("es".to_string()));
|
||||
assert_eq!(resolve_language(Some("fr"), true), Some("fr".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_language_forces_english_on_english_only_model() {
|
||||
// The footgun guard (T8.7/FR-TRX-4): an English-only model can't
|
||||
// honor a non-English request, so it's forced to "en" rather than
|
||||
// silently producing garbage.
|
||||
assert_eq!(resolve_language(Some("es"), false), Some("en".to_string()));
|
||||
assert_eq!(resolve_language(Some("EN"), false), Some("en".to_string()));
|
||||
// Auto is still allowed on an English-only model — harmless, since
|
||||
// whisper.cpp forces English internally for it either way.
|
||||
assert_eq!(resolve_language(None, false), None);
|
||||
assert_eq!(resolve_language(Some("auto"), false), None);
|
||||
}
|
||||
|
||||
/// Multilingual decode acceptance spike (opt-in, mirrors
|
||||
/// `gpu_transcribes_and_times`): loads a *multilingual* model with an
|
||||
/// explicit non-English `language` and confirms it decodes non-empty
|
||||
/// text without being forced to English. Needs a real multilingual ggml
|
||||
/// model + a non-English wav, neither of which are fetched by CI/this
|
||||
/// sandbox — run manually:
|
||||
/// WA_WHISPER_MODEL=…ggml-small-q5_1.bin WA_TEST_WAV=…spanish.wav \
|
||||
/// WA_TEST_LANGUAGE=es cargo test multilingual_model_decodes_requested_language \
|
||||
/// -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore = "requires a multilingual whisper model + non-English wav; run manually"]
|
||||
fn multilingual_model_decodes_requested_language() {
|
||||
let model = std::env::var("WA_WHISPER_MODEL").expect("set WA_WHISPER_MODEL");
|
||||
let wav = std::env::var("WA_TEST_WAV").expect("set WA_TEST_WAV");
|
||||
let language = std::env::var("WA_TEST_LANGUAGE").unwrap_or_else(|_| "es".to_string());
|
||||
|
||||
let transcriber =
|
||||
WhisperTranscriber::load(Path::new(&model), BackendId::Cpu, Some(&language))
|
||||
.expect("load multilingual model");
|
||||
assert_eq!(transcriber.effective_language(), Some(language.clone()));
|
||||
|
||||
let segments = transcriber
|
||||
.transcribe_file(Path::new(&wav))
|
||||
.expect("transcribe");
|
||||
let text = segments
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
eprintln!("[spike] language={language} text={text:?}");
|
||||
assert!(!text.trim().is_empty(), "transcript was empty");
|
||||
// whisper.cpp reports back whichever language it actually decoded in.
|
||||
assert_eq!(
|
||||
transcriber.detected_language().as_deref(),
|
||||
Some(language.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
/// "Auto" acceptance spike (opt-in): confirms auto-detection actually
|
||||
/// runs (no `language` forced) and surfaces a detected language after
|
||||
/// decode. Same manual-only posture as the spike above.
|
||||
#[test]
|
||||
#[ignore = "requires a multilingual whisper model + wav; run manually"]
|
||||
fn auto_mode_detects_a_language() {
|
||||
let model = std::env::var("WA_WHISPER_MODEL").expect("set WA_WHISPER_MODEL");
|
||||
let wav = std::env::var("WA_TEST_WAV").expect("set WA_TEST_WAV");
|
||||
|
||||
let transcriber = WhisperTranscriber::load(Path::new(&model), BackendId::Cpu, None)
|
||||
.expect("load multilingual model");
|
||||
assert_eq!(
|
||||
transcriber.effective_language(),
|
||||
None,
|
||||
"auto should stay unset"
|
||||
);
|
||||
assert_eq!(transcriber.detected_language(), None, "nothing decoded yet");
|
||||
|
||||
transcriber
|
||||
.transcribe_file(Path::new(&wav))
|
||||
.expect("transcribe");
|
||||
assert!(
|
||||
transcriber.detected_language().is_some(),
|
||||
"auto mode should report a detected language after a decode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ struct Catalog {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
size_mb: u32,
|
||||
/// `false` for the `.en` (English-only) ggml variants; `true` for the
|
||||
/// multilingual variants, which whisper.cpp ships as the same filename
|
||||
/// minus the `.en` infix (e.g. `ggml-base.bin` vs `ggml-base.en.bin`) —
|
||||
/// same download/install machinery, just a different id/URL (T8.7,
|
||||
/// FR-TRX-4, M4.2).
|
||||
multilingual: bool,
|
||||
}
|
||||
|
||||
const CATALOG: &[Catalog] = &[
|
||||
@@ -19,21 +25,52 @@ const CATALOG: &[Catalog] = &[
|
||||
id: "tiny.en-q5_1",
|
||||
label: "Tiny (English, quantized) — fastest, least accurate",
|
||||
size_mb: 32,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "base.en-q5_1",
|
||||
label: "Base (English, quantized) — balanced default",
|
||||
size_mb: 60,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "small.en-q5_1",
|
||||
label: "Small (English, quantized) — more accurate, slower",
|
||||
size_mb: 190,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "medium.en-q5_1",
|
||||
// ggerganov/whisper.cpp only ships a q5_0 quantization for medium
|
||||
// (q5_1 doesn't exist upstream for this size) — q5_1 here 404s.
|
||||
id: "medium.en-q5_0",
|
||||
label: "Medium (English, quantized) — best accuracy, slowest",
|
||||
size_mb: 540,
|
||||
multilingual: false,
|
||||
},
|
||||
Catalog {
|
||||
id: "tiny-q5_1",
|
||||
label: "Tiny (multilingual, quantized) — fastest, least accurate",
|
||||
size_mb: 32,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "base-q5_1",
|
||||
label: "Base (multilingual, quantized) — balanced default",
|
||||
size_mb: 60,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
id: "small-q5_1",
|
||||
label: "Small (multilingual, quantized) — more accurate, slower",
|
||||
size_mb: 190,
|
||||
multilingual: true,
|
||||
},
|
||||
Catalog {
|
||||
// Same upstream-availability caveat as medium.en above.
|
||||
id: "medium-q5_0",
|
||||
label: "Medium (multilingual, quantized) — best accuracy, slowest",
|
||||
size_mb: 540,
|
||||
multilingual: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,10 +87,19 @@ pub fn list(active_id: &str) -> Vec<ModelInfo> {
|
||||
size_mb: m.size_mb,
|
||||
installed: whisper_model_file(m.id).exists(),
|
||||
active: m.id == active_id,
|
||||
multilingual: m.multilingual,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `id` names a multilingual (non-`.en`) catalog model — an unknown
|
||||
/// id (shouldn't happen; callers validate against the catalog first) is
|
||||
/// conservatively treated as English-only rather than granting language
|
||||
/// selection it can't honor.
|
||||
pub fn is_multilingual(id: &str) -> bool {
|
||||
CATALOG.iter().any(|m| m.id == id && m.multilingual)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ModelError {
|
||||
#[error("unknown model id: {0}")]
|
||||
@@ -147,4 +193,38 @@ mod tests {
|
||||
let err = remove(active, active).unwrap_err();
|
||||
assert!(matches!(err, ModelError::Invalid(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_has_a_multilingual_counterpart_for_every_english_only_size() {
|
||||
// T8.7/M4.2: every `.en` model must have a same-size multilingual
|
||||
// sibling so the Settings picker always has a language-capable
|
||||
// option at whatever accuracy/speed tier the user already chose.
|
||||
let en_only: Vec<_> = CATALOG.iter().filter(|m| !m.multilingual).collect();
|
||||
let multilingual: Vec<_> = CATALOG.iter().filter(|m| m.multilingual).collect();
|
||||
assert_eq!(en_only.len(), multilingual.len());
|
||||
for en in &en_only {
|
||||
assert!(
|
||||
multilingual.iter().any(|m| m.size_mb == en.size_mb),
|
||||
"no multilingual sibling for {}",
|
||||
en.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_multilingual_matches_the_catalog_flag() {
|
||||
assert!(!is_multilingual("base.en-q5_1"));
|
||||
assert!(is_multilingual("base-q5_1"));
|
||||
// Unknown ids are conservatively English-only (no model to check).
|
||||
assert!(!is_multilingual("nonexistent-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_reports_multilingual_flag_per_model() {
|
||||
let models = list("base.en-q5_1");
|
||||
let base_en = models.iter().find(|m| m.id == "base.en-q5_1").unwrap();
|
||||
let base_multi = models.iter().find(|m| m.id == "base-q5_1").unwrap();
|
||||
assert!(!base_en.multilingual);
|
||||
assert!(base_multi.multilingual);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,24 @@ impl Transcriber for OnnxTranscriber {
|
||||
/// `Npu` → OpenVINO EP; `Amd`/`Intel` → DirectML EP (the non-Vulkan GPU
|
||||
/// path). Any other value is rejected so the dispatcher can fall back to
|
||||
/// whisper.cpp rather than us guessing.
|
||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError> {
|
||||
///
|
||||
/// `language` (T8.7, FR-TRX-4): the exported ONNX model's
|
||||
/// `forced_decoder_ids` bakes its language in at export time — this
|
||||
/// engine has no per-inference language selection to apply, unlike
|
||||
/// whisper.cpp's `FullParams::set_language`. A non-English, non-auto
|
||||
/// request is logged (not silently dropped) so a user picking a language
|
||||
/// on the NPU/DirectML path finds out it didn't take, rather than
|
||||
/// getting a quietly-wrong transcript; `effective_language`/
|
||||
/// `detected_language` fall back to the trait's English-only defaults.
|
||||
fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result<Self, TrxError> {
|
||||
if let Some(lang) = language {
|
||||
if !lang.eq_ignore_ascii_case("auto") && !lang.eq_ignore_ascii_case("en") {
|
||||
tracing::warn!(
|
||||
"language '{lang}' requested but the NPU/DirectML engine's ONNX model is \
|
||||
English-only (language is fixed at export time); ignoring the request"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pick the runtime bundle + encoder EP for the requested accelerator.
|
||||
// error_on_failure makes a failed accelerator registration LOUD (Err)
|
||||
// instead of a silent CPU fallback, so the dispatcher can cleanly drop
|
||||
@@ -465,7 +482,7 @@ mod tests {
|
||||
};
|
||||
let wav = std::env::var("WA_NPU_TEST_WAV").expect("set WA_NPU_TEST_WAV");
|
||||
let t0 = std::time::Instant::now();
|
||||
let t = OnnxTranscriber::load(Path::new(&model_dir), BackendId::Npu)
|
||||
let t = OnnxTranscriber::load(Path::new(&model_dir), BackendId::Npu, None)
|
||||
.expect("load NPU transcriber");
|
||||
let load_ms = t0.elapsed().as_millis();
|
||||
let t1 = std::time::Instant::now();
|
||||
@@ -507,7 +524,7 @@ mod tests {
|
||||
_ => BackendId::Intel,
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let t = OnnxTranscriber::load(Path::new(&model_dir), backend).expect("load DirectML");
|
||||
let t = OnnxTranscriber::load(Path::new(&model_dir), backend, None).expect("load DirectML");
|
||||
let load_ms = t0.elapsed().as_millis();
|
||||
let t1 = std::time::Instant::now();
|
||||
let segs = t.transcribe_file(Path::new(&wav)).expect("transcribe");
|
||||
|
||||
@@ -216,6 +216,12 @@ pub fn seal(plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `data` begins with the vault's sealed-file magic (i.e. it's
|
||||
/// ciphertext at rest, not a plaintext/pre-vault file).
|
||||
pub fn is_sealed(data: &[u8]) -> bool {
|
||||
data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
|
||||
}
|
||||
|
||||
/// Inverse of `seal`. Plaintext (no magic) passes through unchanged; sealed data
|
||||
/// requires the vault to be unlocked.
|
||||
pub fn open(data: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "WhispAssist",
|
||||
"version": "0.1.5",
|
||||
"version": "0.4.0",
|
||||
"identifier": "bet.dou.whispassist",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; 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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "gen/schemas/desktop-schema.json",
|
||||
"bundle": {
|
||||
"resources": ["vulkan-1.dll"]
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ async fn attach_meeting_to_event_and_map_speaker_to_participant_round_trip() {
|
||||
title: "Test meeting".to_string(),
|
||||
calendar_event_id: None,
|
||||
template_id: None,
|
||||
language: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
+275
-43
@@ -7,17 +7,40 @@
|
||||
import Settings from "./lib/views/Settings.svelte";
|
||||
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
||||
import LevelMeter from "./lib/components/LevelMeter.svelte";
|
||||
import ImportMeeting from "./lib/components/ImportMeeting.svelte";
|
||||
import { trapFocus } from "./lib/actions/trapFocus";
|
||||
import { recording } from "./lib/stores/recording.svelte";
|
||||
import { settings } from "./lib/stores/settings.svelte";
|
||||
import { meetings } from "./lib/stores/meetings.svelte";
|
||||
import { api, type NoteTemplate } from "./lib/api";
|
||||
import { calendar } from "./lib/stores/calendar.svelte";
|
||||
import { api, type NoteTemplate, type CalendarEvent } from "./lib/api";
|
||||
import { onMount } from "svelte";
|
||||
import ThemeToggle from "./lib/components/ThemeToggle.svelte";
|
||||
import { Circle, Square, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
|
||||
import Splitter from "./lib/components/Splitter.svelte";
|
||||
import { layout, clamp } from "./lib/stores/layout.svelte";
|
||||
import { t } from "./lib/i18n/index.svelte";
|
||||
import {
|
||||
Circle,
|
||||
Square,
|
||||
Trash2,
|
||||
FilePlus,
|
||||
Settings as SettingsIcon,
|
||||
AlertTriangle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let showConsent = $state(false);
|
||||
let showImport = $state(false);
|
||||
|
||||
// A freshly imported meeting: refresh the list and open it.
|
||||
async function onImported(id: string) {
|
||||
await meetings.load();
|
||||
await meetings.select(id);
|
||||
}
|
||||
|
||||
// Vault unlock gate (T8.8): if the vault is enabled but locked at startup,
|
||||
// prompt for the password so encrypted meetings are readable.
|
||||
@@ -40,7 +63,7 @@
|
||||
vaultLocked = false;
|
||||
meetings.load(); // refresh now that encrypted content is readable
|
||||
} catch {
|
||||
vaultErr = "Incorrect password";
|
||||
vaultErr = t("app.vault_incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +90,52 @@
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
});
|
||||
|
||||
// Auto-start recording on calendar events (opt-in, FR-CAL). No background
|
||||
// work: while the app is open we arm a single one-shot timer to the next
|
||||
// event's start; nothing polls (NFR-RES-1). Events already auto-started this
|
||||
// session are remembered so stopping a recording doesn't re-trigger the same
|
||||
// one, and the horizon caps the timer at setTimeout's safe range.
|
||||
let autoRecordTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const autoStarted = new Set<string>();
|
||||
const AUTO_RECORD_HORIZON_MS = 24 * 60 * 60 * 1000;
|
||||
function autoStartForEvent(ev: CalendarEvent) {
|
||||
if (recording.state !== "idle") return;
|
||||
autoStarted.add(ev.id);
|
||||
meetings.deselect();
|
||||
recording.start(
|
||||
ev.subject ?? undefined,
|
||||
settings.settings.default_record,
|
||||
selectedTemplateId || undefined,
|
||||
ev.id,
|
||||
);
|
||||
}
|
||||
$effect(() => {
|
||||
if (!settings.settings.auto_record_calendar || recording.state !== "idle") return;
|
||||
const now = Date.now();
|
||||
const next = calendar.events
|
||||
.filter((e) => e.starts_at && !autoStarted.has(e.id))
|
||||
.filter((e) => ((e.ends_at ?? e.starts_at) as number) * 1000 > now) // not already over
|
||||
.sort((a, b) => (a.starts_at as number) - (b.starts_at as number))[0];
|
||||
if (!next) return;
|
||||
const delay = (next.starts_at as number) * 1000 - now;
|
||||
if (delay <= 0) {
|
||||
autoStartForEvent(next); // event is happening right now
|
||||
return;
|
||||
}
|
||||
if (delay > AUTO_RECORD_HORIZON_MS) return; // too far out; re-armed on state/event change
|
||||
autoRecordTimer = setTimeout(() => autoStartForEvent(next), delay);
|
||||
return () => clearTimeout(autoRecordTimer);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
recording.init();
|
||||
settings.load();
|
||||
meetings.init();
|
||||
calendar.load(); // events power the auto-record timer above
|
||||
checkVault();
|
||||
api
|
||||
.listNoteTemplates()
|
||||
.then((t) => (noteTemplates = t))
|
||||
.then((tpls) => (noteTemplates = tpls))
|
||||
.catch(() => (noteTemplates = []));
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -99,6 +160,12 @@
|
||||
else recording.stop();
|
||||
}
|
||||
|
||||
async function cancelRecording() {
|
||||
if (!confirm(t("app.discard_confirm"))) return;
|
||||
await recording.cancel();
|
||||
meetings.deselect();
|
||||
}
|
||||
|
||||
// Global shortcuts (T7.4, FR-UX-3): record start/stop, view toggles.
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
@@ -120,9 +187,9 @@
|
||||
$effect(() => {
|
||||
const current = recording.state;
|
||||
if (current !== previousRecordingState) {
|
||||
if (current === "recording") recordingAnnouncement = "Recording started";
|
||||
else if (current === "paused") recordingAnnouncement = "Recording paused";
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = "Recording stopped";
|
||||
if (current === "recording") recordingAnnouncement = t("app.announce_started");
|
||||
else if (current === "paused") recordingAnnouncement = t("app.announce_paused");
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = t("app.announce_stopped");
|
||||
previousRecordingState = current;
|
||||
}
|
||||
});
|
||||
@@ -166,55 +233,73 @@
|
||||
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
|
||||
<header class="bar">
|
||||
<strong>WhispAssist</strong>
|
||||
<span class="muted">local · private</span>
|
||||
<span class="muted">{t("app.tagline")}</span>
|
||||
<div class="spacer"></div>
|
||||
{#if recording.state === "idle"}
|
||||
<select
|
||||
class="theme-select"
|
||||
bind:value={selectedTemplateId}
|
||||
aria-label="Note template"
|
||||
title="Note template"
|
||||
aria-label={t("app.note_template")}
|
||||
title={t("app.note_template")}
|
||||
>
|
||||
<option value="">No template</option>
|
||||
{#each noteTemplates as t (t.id)}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
<option value="">{t("app.no_template")}</option>
|
||||
{#each noteTemplates as tpl (tpl.id)}
|
||||
<option value={tpl.id}>{tpl.name}</option>
|
||||
{/each}
|
||||
</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="Start recording (Ctrl+Shift+R)"
|
||||
title={t("app.record_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Circle size={11} fill="currentColor" aria-hidden="true" />
|
||||
Record
|
||||
{t("app.record")}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="stop-btn"
|
||||
onclick={() => recording.stop()}
|
||||
title="Stop recording (Ctrl+Shift+R)"
|
||||
title={t("app.stop_title")}
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<Square size={11} fill="currentColor" aria-hidden="true" />
|
||||
Stop
|
||||
{t("app.stop")}
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={cancelRecording} title={t("app.cancel_title")}>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
{t("app.cancel")}
|
||||
</button>
|
||||
<span class="rec">
|
||||
<span class="rec-dot" aria-hidden="true"></span>
|
||||
Recording…
|
||||
{t("app.recording")}
|
||||
</span>
|
||||
<LevelMeter rms={recording.levelRms} peak={recording.levelPeak} />
|
||||
<LevelMeter
|
||||
rms={recording.levelRms}
|
||||
peak={recording.levelPeak}
|
||||
micRms={recording.levelRmsMic}
|
||||
micPeak={recording.levelPeakMic}
|
||||
showMic={settings.settings.microphone_enabled}
|
||||
/>
|
||||
{#if settings.hardware}
|
||||
<span class="backend" title="Active transcription backend">{settings.hardware.active}</span>
|
||||
<span class="backend" title={t("app.backend_title")}>{settings.hardware.active}</span>
|
||||
{/if}
|
||||
<label class="retention" title="Save audio as .wav for this meeting">
|
||||
<label class="retention" title={t("app.retention_title")}>
|
||||
<input type="checkbox" checked={recording.retention} onchange={onToggleRetention} />
|
||||
<span>{recording.retention ? "saving" : "not saved"}</span>
|
||||
<span>{recording.retention ? t("app.saving") : t("app.not_saved")}</span>
|
||||
</label>
|
||||
{#if recording.deviceNotice}
|
||||
<span class="device-notice" role="status" title={recording.deviceNotice}>
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
reconnecting audio device…
|
||||
{t("app.reconnecting")}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -224,8 +309,8 @@
|
||||
/>
|
||||
<button
|
||||
class="icon"
|
||||
aria-label="Settings"
|
||||
title="Settings (Ctrl+,)"
|
||||
aria-label={t("app.settings")}
|
||||
title={t("app.settings_title")}
|
||||
aria-keyshortcuts="Control+,"
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
>
|
||||
@@ -238,7 +323,7 @@
|
||||
class="consent-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Recording consent"
|
||||
aria-label={t("app.consent_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
@@ -250,37 +335,124 @@
|
||||
<Settings onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showImport}
|
||||
<ImportMeeting onClose={() => (showImport = false)} {onImported} />
|
||||
{/if}
|
||||
|
||||
{#if vaultLocked}
|
||||
<div
|
||||
class="vault-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Unlock encryption vault"
|
||||
aria-label={t("app.vault_dialog")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="vault-card">
|
||||
<h2>Unlock encryption vault</h2>
|
||||
<p class="muted">Your meetings are encrypted at rest. Enter your password to read them.</p>
|
||||
<h2>{t("app.vault_title")}</h2>
|
||||
<p class="muted">{t("app.vault_desc")}</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Vault password"
|
||||
placeholder={t("app.vault_password")}
|
||||
bind:value={vaultPw}
|
||||
onkeydown={(e) => e.key === "Enter" && submitUnlock()}
|
||||
/>
|
||||
{#if vaultErr}<p class="vault-err">{vaultErr}</p>{/if}
|
||||
<div class="vault-actions">
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}>Unlock</button>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}>Continue locked</button>
|
||||
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}
|
||||
>{t("app.unlock")}</button
|
||||
>
|
||||
<button class="link" onclick={() => (vaultLocked = false)}
|
||||
>{t("app.continue_locked")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="panes">
|
||||
<aside class="left"><MeetingsList /></aside>
|
||||
<main
|
||||
class="panes"
|
||||
style="grid-template-columns: {layout.leftCollapsed
|
||||
? 'auto'
|
||||
: layout.leftWidth + 'px'} auto 1fr auto {layout.rightCollapsed
|
||||
? 'auto'
|
||||
: layout.rightWidth + 'px'};"
|
||||
>
|
||||
<aside class="side left" class:collapsed={layout.leftCollapsed}>
|
||||
{#if layout.leftCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={() => {
|
||||
layout.leftCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.show_meetings")}
|
||||
aria-label={t("app.show_meetings")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={() => {
|
||||
layout.leftCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.hide_meetings")}
|
||||
aria-label={t("app.hide_meetings")}
|
||||
>
|
||||
<PanelLeftClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="side-content"><MeetingsList /></div>
|
||||
{/if}
|
||||
</aside>
|
||||
{#if !layout.leftCollapsed}
|
||||
<Splitter
|
||||
label={t("app.resize_meetings")}
|
||||
onResize={(d) => (layout.leftWidth = clamp(layout.leftWidth + d, 200, 480))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<section class="center"><TranscriptNotes /></section>
|
||||
<aside class="right"><SummaryPanel /></aside>
|
||||
{#if !layout.rightCollapsed}
|
||||
<Splitter
|
||||
label={t("app.resize_summary")}
|
||||
onResize={(d) => (layout.rightWidth = clamp(layout.rightWidth - d, 240, 560))}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<aside class="side right" class:collapsed={layout.rightCollapsed}>
|
||||
{#if layout.rightCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={() => {
|
||||
layout.rightCollapsed = false;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.show_summary")}
|
||||
aria-label={t("app.show_summary")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={() => {
|
||||
layout.rightCollapsed = true;
|
||||
layout.persist();
|
||||
}}
|
||||
title={t("app.hide_summary")}
|
||||
aria-label={t("app.hide_summary")}
|
||||
>
|
||||
<PanelRightClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="side-content"><SummaryPanel /></div>
|
||||
{/if}
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -428,7 +600,8 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.record-btn,
|
||||
.stop-btn {
|
||||
.stop-btn,
|
||||
.import-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -443,9 +616,18 @@
|
||||
transform 100ms ease-out;
|
||||
}
|
||||
.record-btn:active,
|
||||
.stop-btn:active {
|
||||
.stop-btn:active,
|
||||
.import-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.import-btn {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.import-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.record-btn {
|
||||
background: var(--danger);
|
||||
color: #ffffff;
|
||||
@@ -461,6 +643,25 @@
|
||||
.stop-btn:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.cancel-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.rec {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -559,25 +760,56 @@
|
||||
}
|
||||
.panes {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 320px;
|
||||
/* grid-template-columns set inline — depends on collapse/resize state (FR-UX-1). */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-color: var(--border);
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.left {
|
||||
.side.left {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.right {
|
||||
.side.right {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.side.collapsed {
|
||||
align-items: center;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
.side-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.pane-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.pane-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.pane-toggle.inline {
|
||||
align-self: flex-end;
|
||||
margin: 0.3rem 0.3rem 0;
|
||||
}
|
||||
.center {
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
min-width: 0;
|
||||
}
|
||||
.vault-overlay {
|
||||
position: fixed;
|
||||
|
||||
+138
-13
@@ -48,8 +48,16 @@ export interface HardwareStatus {
|
||||
directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean };
|
||||
}
|
||||
|
||||
// One enumerated audio device — a render (playback) device for the loopback
|
||||
// picker (FR-CAP-1) or a capture (microphone) device for the mic picker
|
||||
// (FR-CAP-7). `id` is the persisted `Device::get_id()`; `name` is display-only.
|
||||
export interface AudioDeviceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LlmStatus {
|
||||
provider: string; // ollama|custom|off (Phase 10a adds anthropic|openai)
|
||||
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
|
||||
reachable: boolean;
|
||||
isLocal: boolean;
|
||||
models: string[];
|
||||
@@ -61,6 +69,16 @@ export interface ModelInfo {
|
||||
size_mb: number;
|
||||
installed: boolean;
|
||||
active: boolean;
|
||||
/** `false` for `.en` (English-only) ggml variants; `true` for multilingual
|
||||
* ones — gates the Settings language picker (T8.7, FR-TRX-4, M4.2). */
|
||||
multilingual: boolean;
|
||||
}
|
||||
|
||||
// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code
|
||||
// as accepted by whisper.cpp, plus a display label.
|
||||
export interface LanguageOption {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
|
||||
@@ -144,6 +162,9 @@ export interface Meeting {
|
||||
duration_secs: number | null;
|
||||
status: MeetingStatus;
|
||||
recorded: boolean;
|
||||
// Transcription language actually used/selected for this meeting (T8.7,
|
||||
// FR-TRX-4) — an ISO-639-1 code, or null if never resolved (e.g. no audio
|
||||
// was ever decoded).
|
||||
language: string | null;
|
||||
backend_used: string | null;
|
||||
model_used: string | null;
|
||||
@@ -151,6 +172,10 @@ export interface Meeting {
|
||||
speakers: SpeakerInfo[];
|
||||
notes_markdown: string;
|
||||
summary: SummaryFile | null;
|
||||
// Confirmed/edited action items (table-backed source of truth) — falls back
|
||||
// to summary.action_items drafts until anything is saved. Manage via
|
||||
// confirmActionItems (add/edit/delete).
|
||||
action_items: ActionItem[];
|
||||
calendar_event_id: string | null;
|
||||
tags: string[];
|
||||
template_id: string | null;
|
||||
@@ -188,6 +213,11 @@ export interface CalendarEventDetail {
|
||||
participants: Participant[];
|
||||
}
|
||||
|
||||
export interface CalendarCleanupResult {
|
||||
deleted: number;
|
||||
protected: number;
|
||||
}
|
||||
|
||||
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
|
||||
|
||||
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
|
||||
@@ -202,6 +232,13 @@ export interface SyncTargetInfo {
|
||||
enabled: boolean;
|
||||
third_party: boolean;
|
||||
host: string | null;
|
||||
upload_transcript: boolean;
|
||||
upload_notes: boolean;
|
||||
upload_summary: boolean;
|
||||
upload_recording: boolean;
|
||||
trigger_on_finalize: boolean;
|
||||
allow_plaintext_lan: boolean;
|
||||
encrypt_before_upload: boolean;
|
||||
}
|
||||
|
||||
// privacy_self_check() response (FR-SEC-2) — proves local-only handling.
|
||||
@@ -269,13 +306,36 @@ export interface AppSettings {
|
||||
} | null;
|
||||
preferred_backend: string;
|
||||
whisper_model: string;
|
||||
/** Default transcription language (T8.7, FR-TRX-4): `null` = auto-detect,
|
||||
* an ISO-639-1 code forces that language. Only takes effect with a
|
||||
* multilingual model — see `ModelInfo.multilingual`. */
|
||||
whisper_language: string | null;
|
||||
low_overhead: boolean;
|
||||
default_record: boolean;
|
||||
consent_acknowledged: boolean;
|
||||
/** One-time "data leaves your device" ack for hosted (non-local) AI
|
||||
* providers — Anthropic today (ADR-0011, T10.3). Independent of
|
||||
* consent_acknowledged (that one's about recording law). */
|
||||
hosted_ai_acknowledged: boolean;
|
||||
sync_enabled: boolean;
|
||||
mcp_enabled: boolean;
|
||||
mcp_transport: string; // http|stdio
|
||||
mcp_port: number;
|
||||
mcp_expose: string; // none|selected|all
|
||||
mcp_expose_recordings: boolean;
|
||||
retention_max_age_days: number | null;
|
||||
retention_max_size_gb: number | null;
|
||||
pst_last_path: string | null;
|
||||
pst_auto_sync: boolean;
|
||||
// Days of history to import (both a manual Import click and pst_auto_sync);
|
||||
// null = full mailbox history.
|
||||
pst_import_range_days: number | null;
|
||||
/** Auto-start recording when a calendar event begins while the app is open
|
||||
* (opt-in, off by default). Armed as a one-shot UI timer — nothing polls. */
|
||||
auto_record_calendar: boolean;
|
||||
audio_output_device: string | null;
|
||||
microphone_enabled: boolean;
|
||||
audio_input_device: string | null;
|
||||
}
|
||||
|
||||
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
|
||||
@@ -305,39 +365,82 @@ export interface McpAccessEntry {
|
||||
client: string | null;
|
||||
}
|
||||
|
||||
// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled.
|
||||
export interface McpStatus {
|
||||
enabled: boolean;
|
||||
transport: "http" | "stdio";
|
||||
endpoint: string;
|
||||
tokenSet: boolean;
|
||||
exposeScope: "none" | "selected" | "all";
|
||||
}
|
||||
|
||||
// ---- Commands ----
|
||||
export const api = {
|
||||
// `record` controls audio RETENTION (default false / off — ADR-0009).
|
||||
// `language` (T8.7, FR-TRX-4): omit/undefined falls back to
|
||||
// Settings.whisper_language; "auto" or omitted both mean auto-detect.
|
||||
startRecording: (
|
||||
meetingTitle?: string,
|
||||
calendarEventId?: string,
|
||||
record = false,
|
||||
templateId?: string,
|
||||
language?: string,
|
||||
) =>
|
||||
invoke<MeetingId>("start_recording", {
|
||||
args: { meetingTitle, calendarEventId, record, templateId },
|
||||
args: { meetingTitle, calendarEventId, record, templateId, language },
|
||||
}),
|
||||
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
|
||||
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
|
||||
// Abandon an accidental recording: stop + delete files + drop the DB row.
|
||||
cancelRecording: (meetingId: MeetingId) => invoke<void>("cancel_recording", { meetingId }),
|
||||
// Absolute path to a playable audio.wav (decrypted if sealed), for convertFileSrc.
|
||||
recordingPlaybackPath: (meetingId: MeetingId) =>
|
||||
invoke<string>("recording_playback_path", { meetingId }),
|
||||
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
|
||||
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
|
||||
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
|
||||
invoke<void>("set_recording_retention", { meetingId, record }),
|
||||
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
|
||||
|
||||
// Live notes (Granola-style redesign): both live-session only, err once
|
||||
// the meeting is finalized — use updateNotes on the merged notes.md instead.
|
||||
updateLiveNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_live_notes", { meetingId, markdown }),
|
||||
// anchorMs: the clicked segment's start_ms (not its id). text: "" clears it.
|
||||
setSegmentNote: (meetingId: MeetingId, anchorMs: number, text: string) =>
|
||||
invoke<void>("set_segment_note", { meetingId, anchorMs, text }),
|
||||
|
||||
appInfo: () => invoke<AppInfo>("app_info"),
|
||||
openUrl: (url: string) => invoke<void>("open_url", { url }),
|
||||
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
|
||||
listAudioDevices: () => invoke<AudioDeviceInfo[]>("list_audio_devices"),
|
||||
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
|
||||
setPreferredBackend: (backend: BackendId | "auto") =>
|
||||
invoke<void>("set_preferred_backend", { args: { backend } }),
|
||||
downloadNpuPackage: () => invoke<void>("download_npu_package"),
|
||||
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
|
||||
listModels: () => invoke<ModelInfo[]>("list_models"),
|
||||
downloadModel: (id: string, kind: "whisper" = "whisper") =>
|
||||
// The fixed segmentation+embedding pair that speaker diarization needs
|
||||
// installed before it can separate speakers (T4.7, FR-MODEL-1). Same
|
||||
// ModelInfo shape as whisper models; download via `downloadModel` with the
|
||||
// `diar-seg`/`diar-emb` kind, remove via the shared `removeModel`.
|
||||
listDiarizationModels: () => invoke<ModelInfo[]>("list_diarization_models"),
|
||||
// T8.7/FR-TRX-4: static catalog of whisper.cpp-recognized language codes
|
||||
// for the Settings dropdown; "Auto-detect" is a frontend-only addition.
|
||||
listWhisperLanguages: () => invoke<LanguageOption[]>("list_whisper_languages"),
|
||||
downloadModel: (id: string, kind: "whisper" | "diar-seg" | "diar-emb" = "whisper") =>
|
||||
invoke<void>("download_model", { args: { kind, id } }),
|
||||
removeModel: (id: string) => invoke<void>("remove_model", { id }),
|
||||
reprocessTranscript: (meetingId: MeetingId, model: string) =>
|
||||
invoke<void>("reprocess_transcript", { meetingId, model }),
|
||||
// `language` (T8.7): omit/undefined reuses whatever language the meeting
|
||||
// already had rather than resetting it to auto.
|
||||
reprocessTranscript: (meetingId: MeetingId, model: string, language?: string) =>
|
||||
invoke<void>("reprocess_transcript", { meetingId, model, language }),
|
||||
// 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
|
||||
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. Returns the new
|
||||
// meeting's id once transcription + diarization have finished.
|
||||
importMedia: (source: string, title?: string) =>
|
||||
invoke<MeetingId>("import_media", { source, title }),
|
||||
resumeTranscription: (meetingId: MeetingId) =>
|
||||
invoke<void>("resume_transcription", { meetingId }),
|
||||
listMeetings: (filter?: MeetingFilter) =>
|
||||
@@ -357,9 +460,13 @@ export const api = {
|
||||
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
|
||||
updateNotes: (meetingId: MeetingId, markdown: string) =>
|
||||
invoke<void>("update_notes", { meetingId, markdown }),
|
||||
// dest is a file path for md/pdf/docx, a folder for bundle.
|
||||
exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "pdf" | "docx" | "bundle") =>
|
||||
invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// 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.
|
||||
exportMeeting: (
|
||||
meetingId: MeetingId,
|
||||
dest: string,
|
||||
format: "md" | "pdf" | "docx" | "bundle" | "obsidian",
|
||||
) => invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||
// Every meeting matching tag/date filters, one file (or bundle folder) per
|
||||
// meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4).
|
||||
bulkExportMeetings: (
|
||||
@@ -374,6 +481,10 @@ export const api = {
|
||||
from: filter?.from,
|
||||
to: filter?.to,
|
||||
}),
|
||||
// Import bundle(s) exported with format "bundle" — dir is a single bundle
|
||||
// folder or a parent folder of them. Reconstructs each under a fresh id and
|
||||
// returns the count imported (FR-STORE-4).
|
||||
importMeetingBundle: (dir: string) => invoke<number>("import_meeting_bundle", { dir }),
|
||||
|
||||
llmStatus: () => invoke<LlmStatus>("llm_status"),
|
||||
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
|
||||
@@ -387,14 +498,25 @@ export const api = {
|
||||
invoke<void>("generate_summary", { meetingId, templateId }),
|
||||
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
|
||||
invoke<void>("confirm_action_items", { meetingId, items }),
|
||||
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
|
||||
|
||||
importPst: (path: string, password?: string) => invoke<number>("import_pst", { path, password }),
|
||||
// rangeDays: only import events starting within the last N days; omitted/undefined imports
|
||||
// the full mailbox history (a long-lived .pst otherwise re-imports years of recurring/holiday
|
||||
// entries on every launch when pst_auto_sync is on).
|
||||
importPst: (path: string, password?: string, rangeDays?: number) =>
|
||||
invoke<number>("import_pst", { path, password, rangeDays }),
|
||||
// olderThanDays: undefined deletes every unlinked event ("Delete all").
|
||||
// An event attached to a recorded meeting is always kept either way.
|
||||
cleanupCalendarEvents: (olderThanDays?: number) =>
|
||||
invoke<CalendarCleanupResult>("cleanup_calendar_events", { olderThanDays }),
|
||||
listCalendarEvents: (from?: number, to?: number) =>
|
||||
invoke<CalendarEvent[]>("list_calendar_events", { from, to }),
|
||||
getCalendarEvent: (eventId: string) =>
|
||||
invoke<CalendarEventDetail>("get_calendar_event", { eventId }),
|
||||
attachMeetingToEvent: (meetingId: MeetingId, eventId: string) =>
|
||||
invoke<void>("attach_meeting_to_event", { meetingId, eventId }),
|
||||
renameMeeting: (meetingId: MeetingId, title: string) =>
|
||||
invoke<void>("rename_meeting", { meetingId, title }),
|
||||
renameSpeaker: (meetingId: MeetingId, label: string, name: string) =>
|
||||
invoke<void>("rename_speaker", { meetingId, label, name }),
|
||||
mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) =>
|
||||
@@ -425,7 +547,7 @@ export const api = {
|
||||
getFeatureBrief: (id: string) => invoke<FeatureBrief>("get_feature_brief", { id }),
|
||||
setBriefExposed: (id: string, exposed: boolean) =>
|
||||
invoke<void>("set_brief_exposed", { id, exposed }),
|
||||
mcpStatus: () => invoke("mcp_status"),
|
||||
mcpStatus: () => invoke<McpStatus>("mcp_status"),
|
||||
setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) =>
|
||||
invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }),
|
||||
setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) =>
|
||||
@@ -461,7 +583,7 @@ export const events = {
|
||||
onRetention: (cb: (p: { meetingId: string; record: boolean }) => void): Promise<UnlistenFn> =>
|
||||
listen("recording://retention", (e) => cb(e.payload as never)),
|
||||
onLevel: (
|
||||
cb: (p: { meetingId: string; rms: number; peak: number }) => void,
|
||||
cb: (p: { meetingId: string; rms: number; peak: number; mic: boolean }) => void,
|
||||
): Promise<UnlistenFn> => listen("recording://level", (e) => cb(e.payload as never)),
|
||||
onDeviceChanged: (
|
||||
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
|
||||
@@ -508,8 +630,11 @@ export const events = {
|
||||
onSyncLinked: (
|
||||
cb: (p: { ok: boolean; kind: string; error?: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
|
||||
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
|
||||
listen("mcp://access", (e) => cb(e.payload as never)),
|
||||
// Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the
|
||||
// snake_case McpAccessEntry rows `mcpAccessLog()` returns).
|
||||
onMcpAccess: (
|
||||
cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("mcp://access", (e) => cb(e.payload as never)),
|
||||
onAgentProgress: (
|
||||
cb: (p: { briefId: string; tool: string; line: string }) => void,
|
||||
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
// Settings "Record by default" toggle and the mid-meeting retention toggle
|
||||
// so both gate on the same copy and the same acknowledgment call.
|
||||
import { ShieldAlert } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let { onAccept, onCancel }: { onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="consent">
|
||||
<div class="heading">
|
||||
<ShieldAlert size={18} aria-hidden="true" />
|
||||
<strong>Before you record</strong>
|
||||
<strong>{t("consent.heading")}</strong>
|
||||
</div>
|
||||
<p>
|
||||
Recording conversations without the consent of participants may be illegal in your region. Check
|
||||
your local recording laws. This is a caution, not legal advice.
|
||||
</p>
|
||||
<p>{t("consent.body")}</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — enable recording</button>
|
||||
<button onclick={onCancel}>Cancel</button>
|
||||
<button class="primary" onclick={onAccept}>{t("consent.accept")}</button>
|
||||
<button onclick={onCancel}>{t("consent.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
// One-time third-party "data leaves your device" notice (ADR-0011, T10.3/
|
||||
// M3.3), shown before the first use of any hosted (non-local) AI provider —
|
||||
// Anthropic today, a hosted OpenAI-compatible gateway once wired the same
|
||||
// way. Same shared-copy/shared-acknowledgment pattern as ConsentNotice.svelte
|
||||
// (recording consent, ADR-0009): both gate a single Settings toggle AND a
|
||||
// second use-time trigger point on the same one-time flag.
|
||||
import { Globe } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
providerLabel,
|
||||
onAccept,
|
||||
onCancel,
|
||||
}: { providerLabel: string; onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="banner" role="alertdialog" aria-labelledby="hosted-ai-heading">
|
||||
<div class="heading">
|
||||
<Globe size={18} aria-hidden="true" />
|
||||
<strong id="hosted-ai-heading">{t("hosted.heading", { provider: providerLabel })}</strong>
|
||||
</div>
|
||||
<p>
|
||||
{t("hosted.body_1")} <strong>{providerLabel}</strong>
|
||||
{t("hosted.body_2")}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>{t("hosted.accept")}</button>
|
||||
<button class="ghost" onclick={onCancel}>{t("hosted.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
max-width: 420px;
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--warning);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.heading strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
p strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid transparent;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
button.ghost {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
}
|
||||
button.ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
// 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
|
||||
// via ffmpeg (+ yt-dlp for URLs) — both external, not bundled — so this is
|
||||
// just a small form: pick a local file or paste a URL, optional title, go.
|
||||
import { api, errorMessage } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { X, FileUp, Link as LinkIcon } from "@lucide/svelte";
|
||||
|
||||
let { onClose, onImported }: { onClose: () => void; onImported: (id: string) => void } = $props();
|
||||
|
||||
// `source` is either a local file path (set via Browse) or a URL (typed).
|
||||
let source = $state("");
|
||||
let title = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function browse() {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: t("import.filter_av"),
|
||||
extensions: [
|
||||
"mp3",
|
||||
"m4a",
|
||||
"wav",
|
||||
"aac",
|
||||
"ogg",
|
||||
"opus",
|
||||
"flac",
|
||||
"mp4",
|
||||
"mkv",
|
||||
"mov",
|
||||
"webm",
|
||||
"avi",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (typeof path === "string") {
|
||||
source = path;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!source.trim() || busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const id = await api.importMedia(source.trim(), title.trim() || undefined);
|
||||
onImported(id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("import.dialog_label")}
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="panel">
|
||||
<header>
|
||||
<strong>{t("import.title")}</strong>
|
||||
<button
|
||||
class="close"
|
||||
onclick={onClose}
|
||||
aria-label={t("import.close")}
|
||||
title={t("import.close_title")}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="muted">{t("import.body")}</p>
|
||||
|
||||
<label class="wide">
|
||||
{t("import.file_or_url")}
|
||||
<div class="row">
|
||||
<input
|
||||
class="grow"
|
||||
bind:value={source}
|
||||
placeholder={t("import.source_placeholder")}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button onclick={browse} disabled={busy} title={t("import.choose_file")}>
|
||||
<FileUp size={14} aria-hidden="true" />
|
||||
{t("import.browse")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="wide">
|
||||
{t("import.title_label")} <em>({t("import.optional")})</em>
|
||||
<input bind:value={title} placeholder={t("import.title_placeholder")} disabled={busy} />
|
||||
</label>
|
||||
|
||||
<p class="muted small">
|
||||
<LinkIcon size={12} aria-hidden="true" />
|
||||
{t("import.requires_1")} <code>ffmpeg</code>
|
||||
{t("import.requires_2")} <code>yt-dlp</code>
|
||||
{t("import.requires_3")}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={doImport} disabled={!source.trim() || busy}>
|
||||
{busy ? t("import.importing") : t("import.import")}
|
||||
</button>
|
||||
<button class="link" onclick={onClose} disabled={busy}>{t("import.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
width: min(520px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
header strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
label em {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.row .grow {
|
||||
flex: 1;
|
||||
}
|
||||
.row button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
white-space: nowrap;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.4rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.small {
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #d33);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.actions .primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg, #fff);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions .primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.actions .link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,46 @@
|
||||
<script lang="ts">
|
||||
// Live input level meter while recording (T7.3, FR-CAP-5). A simple bar
|
||||
// (rms fill + peak marker) rather than a full scrolling waveform — either
|
||||
// satisfies the requirement, and this is far less UI/state to get right.
|
||||
let { rms, peak }: { rms: number; peak: number } = $props();
|
||||
// Live input level meter while recording (T7.3, FR-CAP-5/7). One bar with the
|
||||
// system/loopback level (green) and — when the mic is enabled — the microphone
|
||||
// level overlaid in the accent colour, so both sides of the call are visible
|
||||
// at a glance. Each stream shows an rms fill + a peak marker.
|
||||
import { t } from "../i18n/index.svelte";
|
||||
let {
|
||||
rms,
|
||||
peak,
|
||||
micRms = 0,
|
||||
micPeak = 0,
|
||||
showMic = false,
|
||||
}: {
|
||||
rms: number;
|
||||
peak: number;
|
||||
micRms?: number;
|
||||
micPeak?: number;
|
||||
showMic?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Perceptual loudness isn't linear; sqrt gives a meter that "looks right"
|
||||
// for typical speech levels instead of sitting near-empty most of the time.
|
||||
let rmsPct = $derived(Math.min(1, Math.sqrt(Math.max(0, rms))) * 100);
|
||||
let peakPct = $derived(Math.min(1, Math.sqrt(Math.max(0, peak))) * 100);
|
||||
const pct = (v: number) => Math.min(1, Math.sqrt(Math.max(0, v))) * 100;
|
||||
let rmsPct = $derived(pct(rms));
|
||||
let peakPct = $derived(pct(peak));
|
||||
let micRmsPct = $derived(pct(micRms));
|
||||
let micPeakPct = $derived(pct(micPeak));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="meter"
|
||||
role="meter"
|
||||
aria-label="Input level"
|
||||
aria-valuenow={Math.round(rmsPct)}
|
||||
aria-label={showMic ? t("levelmeter.system_mic") : t("levelmeter.system")}
|
||||
aria-valuenow={Math.round(Math.max(rmsPct, showMic ? micRmsPct : 0))}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div class="fill" style="width: {rmsPct}%"></div>
|
||||
<div class="peak" style="left: {peakPct}%"></div>
|
||||
<div class="fill system" style="width: {rmsPct}%"></div>
|
||||
<div class="peak system" style="left: {peakPct}%"></div>
|
||||
{#if showMic}
|
||||
<div class="fill mic" style="width: {micRmsPct}%"></div>
|
||||
<div class="peak mic" style="left: {micPeakPct}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -34,16 +55,28 @@
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--success);
|
||||
transition: width 60ms linear;
|
||||
/* Overlap is visible because the mic layer is translucent. */
|
||||
opacity: 0.7;
|
||||
}
|
||||
.fill.system {
|
||||
background: var(--success);
|
||||
}
|
||||
.fill.mic {
|
||||
background: var(--accent);
|
||||
}
|
||||
.peak {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--fg);
|
||||
opacity: 0.6;
|
||||
transition: left 60ms linear;
|
||||
}
|
||||
.peak.system {
|
||||
background: var(--fg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.peak.mic {
|
||||
background: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
// Draggable divider between two panes (FR-UX-1). Reports a delta in
|
||||
// pixels via onResize as the pointer moves; the parent owns the actual
|
||||
// size state and clamping. onResizeEnd fires once per drag (and per arrow
|
||||
// keypress) so the parent can persist without writing on every pixel.
|
||||
let {
|
||||
orientation = "vertical",
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
label,
|
||||
}: {
|
||||
orientation?: "vertical" | "horizontal";
|
||||
onResize: (deltaPx: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
label: string;
|
||||
} = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let lastPos = 0;
|
||||
|
||||
function posOf(e: PointerEvent): number {
|
||||
return orientation === "vertical" ? e.clientX : e.clientY;
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
dragging = true;
|
||||
lastPos = posOf(e);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
const pos = posOf(e);
|
||||
onResize(pos - lastPos);
|
||||
lastPos = pos;
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
onResizeEnd?.();
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 40 : 12;
|
||||
const negKey = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
|
||||
const posKey = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
|
||||
if (e.key === negKey) onResize(-step);
|
||||
else if (e.key === posKey) onResize(step);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
onResizeEnd?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- WAI-ARIA "window splitter" pattern: a focusable, keyboard-operable
|
||||
role="separator" is the correct/standard shape for a resize handle —
|
||||
the a11y linter's generic "non-interactive element" rule doesn't know
|
||||
about this pattern specifically. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="splitter {orientation}"
|
||||
class:dragging
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
aria-label={label}
|
||||
tabindex="0"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onkeydown={onKeydown}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.splitter {
|
||||
flex: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.splitter.vertical {
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.splitter.horizontal {
|
||||
height: 5px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
/* Wider invisible hit-area than the visible line — a 5px target is too
|
||||
thin to reliably grab (touch-target-size / no-precision-required). */
|
||||
.splitter::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.splitter.vertical::after {
|
||||
inset: 0 -4px;
|
||||
}
|
||||
.splitter.horizontal::after {
|
||||
inset: -4px 0;
|
||||
}
|
||||
.splitter:hover,
|
||||
.splitter:focus-visible,
|
||||
.splitter.dragging {
|
||||
background: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
// GitHub-topic-style pill (T8.3, FR-SEARCH-2). Clicking the label filters
|
||||
// the meeting list to this tag; the optional 'x' removes it from whatever
|
||||
// list it's rendered in (not a global delete — the caller decides what
|
||||
// "remove" means).
|
||||
import { X } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
let { tag, removable = false, onRemove, onClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="chip" class:clickable={!!onClick}>
|
||||
<button
|
||||
type="button"
|
||||
class="label"
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
title={onClick ? t("tagchip.filter", { tag }) : undefined}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{#if removable}
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={onRemove}
|
||||
aria-label={t("tagchip.remove", { tag })}
|
||||
>
|
||||
<X size={10} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.label {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0.15rem 0.65rem;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: default;
|
||||
}
|
||||
.label:disabled {
|
||||
opacity: 1; /* a non-clickable chip should still read as fully legible */
|
||||
}
|
||||
.chip.clickable .label {
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.clickable .label:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.label:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.remove {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
margin: 0 0.3rem 0 -0.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
opacity 150ms ease-out;
|
||||
}
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.remove:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
// plain <select> — three icon buttons instead of a text dropdown, defaults
|
||||
// to "system" so the app follows the OS until the user picks an override.
|
||||
import { Monitor, Sun, Moon } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
interface Props {
|
||||
value: "system" | "light" | "dark";
|
||||
@@ -10,21 +11,23 @@
|
||||
}
|
||||
let { value, onchange }: Props = $props();
|
||||
|
||||
// labelKey resolves through t() in the template so a language switch relabels
|
||||
// the buttons (a t() call in this module-level const would evaluate once).
|
||||
const options = [
|
||||
{ id: "system", label: "Match system", Icon: Monitor },
|
||||
{ id: "light", label: "Light", Icon: Sun },
|
||||
{ id: "dark", label: "Dark", Icon: Moon },
|
||||
{ id: "system", labelKey: "theme.system", Icon: Monitor },
|
||||
{ id: "light", labelKey: "theme.light", Icon: Sun },
|
||||
{ id: "dark", labelKey: "theme.dark", Icon: Moon },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="toggle" role="radiogroup" aria-label="Theme">
|
||||
<div class="toggle" role="radiogroup" aria-label={t("theme.group")}>
|
||||
{#each options as opt (opt.id)}
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={value === opt.id}
|
||||
class:active={value === opt.id}
|
||||
title={opt.label}
|
||||
aria-label={opt.label}
|
||||
title={t(opt.labelKey)}
|
||||
aria-label={t(opt.labelKey)}
|
||||
onclick={() => onchange(opt.id)}
|
||||
>
|
||||
<opt.Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
{
|
||||
"nav.recording": "Recording",
|
||||
"nav.hardware": "Hardware",
|
||||
"nav.storage": "Storage",
|
||||
"nav.calendar": "Calendar",
|
||||
"nav.sync": "Sync",
|
||||
"nav.ai": "AI",
|
||||
"nav.mcp": "MCP server",
|
||||
"nav.privacy": "Privacy",
|
||||
"nav.about": "About",
|
||||
"nav.language": "Language",
|
||||
|
||||
"settings.language.title": "Language",
|
||||
"settings.language.display": "Display language",
|
||||
"settings.language.display_hint": "The language of the app interface. Adding a language is as simple as dropping in one translation file.",
|
||||
|
||||
"settings.transcription.title": "Transcription Language",
|
||||
"settings.transcription.label": "Language",
|
||||
"settings.transcription.auto": "Auto-detect",
|
||||
"settings.transcription.applies_hint": "Applies to the next recording. The language actually used is shown on each meeting afterward.",
|
||||
"settings.transcription.english_only": "English only",
|
||||
"settings.transcription.model_english_only": "\"{model}\" is English-only.",
|
||||
"settings.transcription.no_model": "No model selected.",
|
||||
"settings.transcription.switch_multilingual": "Switch to a multilingual model above to choose a language.",
|
||||
|
||||
"settings.hardware.title": "Hardware",
|
||||
"settings.hardware.active_backend": "Active backend",
|
||||
"settings.hardware.model_meta": "· model {size}",
|
||||
"settings.hardware.preferred_backend": "Preferred backend",
|
||||
"settings.hardware.auto_backend": "Auto (best available)",
|
||||
"settings.hardware.not_available": "not available",
|
||||
"settings.hardware.fallback_hint": "Falls back automatically (NPU → NVIDIA → AMD → Intel → CPU) if the chosen backend fails to load.",
|
||||
"settings.hardware.audio_devices": "Audio Devices",
|
||||
"settings.hardware.recording_device": "Recording device",
|
||||
"settings.hardware.default_system_audio": "Default system audio",
|
||||
"settings.hardware.recording_device_hint": "WhispAssist records whatever this device plays (loopback) — the other side of the call. Pick a specific output if you don't want it following Windows' system default.",
|
||||
"settings.hardware.microphone": "Microphone",
|
||||
"settings.hardware.mic_off": "Off — don't capture my microphone",
|
||||
"settings.hardware.default_mic": "Default microphone",
|
||||
"settings.hardware.mic_hint": "Adds your own voice to the live transcript so both sides of the meeting are captured. Stays on your device — nothing is uploaded. Choose “Off” to transcribe only the system audio above.",
|
||||
"settings.hardware.npu_detected": "NPU accelerator detected",
|
||||
"settings.hardware.ready": "Ready",
|
||||
"settings.hardware.downloading": "Downloading…",
|
||||
"settings.hardware.package_needed": "Package needed",
|
||||
"settings.hardware.npu_hint": "Your NPU needs a one-time acceleration package (Whisper ONNX model{extra}). It downloads in the background; you can also start it now.",
|
||||
"settings.hardware.plus_openvino": " + OpenVINO runtime",
|
||||
"settings.hardware.download_npu": "Download NPU package",
|
||||
"settings.hardware.dml_title": "GPU acceleration (DirectML)",
|
||||
"settings.hardware.dml_hint": "Your GPU can be accelerated without Vulkan via DirectML — a one-time package (Whisper ONNX model{extra}).",
|
||||
"settings.hardware.plus_directml": " + DirectML runtime",
|
||||
"settings.hardware.download_dml": "Download DirectML package",
|
||||
"settings.hardware.detection_unavailable": "Hardware detection unavailable.",
|
||||
"settings.hardware.low_overhead": "Low overhead preset",
|
||||
"settings.hardware.low_overhead_note": "CPU + smallest model, for battery/background use",
|
||||
"settings.hardware.models": "Models",
|
||||
"settings.hardware.downloading_model": "Downloading {name}",
|
||||
"settings.hardware.active_badge": "Active",
|
||||
"settings.hardware.installed": "Installed",
|
||||
"settings.hardware.use": "Use",
|
||||
"settings.hardware.remove": "Remove",
|
||||
"settings.hardware.cant_remove_active": "Can't remove the active model",
|
||||
"settings.hardware.download": "Download",
|
||||
"settings.hardware.diarization": "Speaker diarization",
|
||||
"settings.hardware.diarization_hint": "Install both models to have finished recordings separated by speaker (Speaker 1, Speaker 2, …) instead of one running transcript. Runs fully offline, once per meeting after it stops. Without them, every line is attributed to a single speaker.",
|
||||
|
||||
"settings.storage.title": "Storage",
|
||||
"settings.storage.location_label": "Location",
|
||||
"settings.storage.location_hint": "Changing the storage location isn't supported yet — this is where meetings, models, and the database currently live.",
|
||||
"settings.storage.retention_title": "Retention",
|
||||
"settings.storage.retention_hint": "Automatically delete the oldest meetings past a limit. Checked once at startup; empty means no limit. Never touches a meeting that's currently recording.",
|
||||
"settings.storage.max_age": "Max age (days)",
|
||||
"settings.storage.max_size": "Max size (GB)",
|
||||
"settings.storage.no_limit": "no limit",
|
||||
"settings.storage.export_import_title": "Export & import",
|
||||
"settings.storage.export_hint_1": "Move recordings between computers as portable bundle folders (audio, transcript, notes, summary, and a",
|
||||
"settings.storage.export_hint_2": "manifest). Export to any folder — a synced drive, a USB stick, or a sync target's local mount — then import it on the other machine. Imported meetings get a fresh id, so re-importing never overwrites anything.",
|
||||
"settings.storage.export_dialog_title": "Export all meetings into…",
|
||||
"settings.storage.import_dialog_title": "Import a bundle (or a folder of bundles)…",
|
||||
"settings.storage.exporting": "Exporting…",
|
||||
"settings.storage.export_all": "Export all meetings…",
|
||||
"settings.storage.importing": "Importing…",
|
||||
"settings.storage.import_all": "Import meetings…",
|
||||
"settings.storage.exported_one": "Exported {n} meeting.",
|
||||
"settings.storage.exported_many": "Exported {n} meetings.",
|
||||
"settings.storage.imported_one": "Imported {n} meeting.",
|
||||
"settings.storage.imported_many": "Imported {n} meetings.",
|
||||
|
||||
"settings.calendar.title": "Calendar & Outlook .pst",
|
||||
"settings.calendar.intro_1": "Import events and attendees from a local Outlook",
|
||||
"settings.calendar.intro_2": "backup — read-only, nothing is written back to the file. Nothing leaves this device.",
|
||||
"settings.calendar.pst_file": ".pst file",
|
||||
"settings.calendar.no_file": "No file selected",
|
||||
"settings.calendar.browse": "Browse…",
|
||||
"settings.calendar.password": "Password",
|
||||
"settings.calendar.password_note": "rarely needed",
|
||||
"settings.calendar.optional": "optional",
|
||||
"settings.calendar.import_range": "Import range",
|
||||
"settings.calendar.range_30": "Last 30 days",
|
||||
"settings.calendar.range_90": "Last 90 days",
|
||||
"settings.calendar.range_180": "Last 6 months",
|
||||
"settings.calendar.range_365": "Last 1 year",
|
||||
"settings.calendar.range_all": "All time",
|
||||
"settings.calendar.range_hint": "A long-lived mailbox can hold years of recurring/holiday entries — narrowing the range keeps the imported calendar to what's actually relevant. Applies to both this Import button and automatic re-import on launch.",
|
||||
"settings.calendar.importing": "Importing…",
|
||||
"settings.calendar.import": "Import",
|
||||
"settings.calendar.import_progress": "{processed}/{total} events",
|
||||
"settings.calendar.auto_reimport": "Re-import this file automatically on launch",
|
||||
"settings.calendar.auto_reimport_hint": "Runs once at startup, not on a timer — re-import is safe to repeat (existing events are matched and updated, not duplicated).",
|
||||
"settings.calendar.import_failed": "Import failed: {error} — the file itself is untouched; check the path and try again.",
|
||||
"settings.calendar.cleanup_title": "Clean up",
|
||||
"settings.calendar.cleanup_hint": "Events already attached to a recorded meeting are always kept, no matter which option below is picked.",
|
||||
"settings.calendar.cleanup_30": "Older than 30 days",
|
||||
"settings.calendar.cleanup_90": "Older than 90 days",
|
||||
"settings.calendar.cleanup_180": "Older than 6 months",
|
||||
"settings.calendar.cleanup_365": "Older than 1 year",
|
||||
"settings.calendar.cleanup_all": "Delete all",
|
||||
"settings.calendar.cleaning": "Cleaning up…",
|
||||
"settings.calendar.cleanup_btn": "Clean up calendar",
|
||||
"settings.calendar.cleanup_result": "Deleted {deleted}{extra}.",
|
||||
"settings.calendar.cleanup_kept": ", kept {n} (linked to a meeting)",
|
||||
"settings.calendar.cleanup_failed": "Clean up failed: {error}",
|
||||
"settings.calendar.imported_events": "Imported events",
|
||||
"settings.calendar.no_events": "No events imported yet.",
|
||||
"settings.calendar.search_title": "Search title",
|
||||
"settings.calendar.search_placeholder": "Meeting name…",
|
||||
"settings.calendar.date_label": "Date",
|
||||
"settings.calendar.events_count": "{shown} of {total} events",
|
||||
"settings.calendar.untitled": "(untitled)",
|
||||
|
||||
"settings.dialog_title": "Settings",
|
||||
"settings.close": "Close",
|
||||
"settings.close_title": "Close (Esc)",
|
||||
|
||||
"settings.sync.stub": "Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI only.",
|
||||
"settings.sync.title": "Sync & upload",
|
||||
"settings.sync.enable_label": "Enable uploading meeting artifacts to configured targets",
|
||||
"settings.sync.enable_hint": "Off by default. Nothing is uploaded unless this is on and a target is enabled. Self-hosted targets keep data on your own server; third-party clouds are clearly labeled.",
|
||||
"settings.sync.targets_title": "Targets",
|
||||
"settings.sync.no_targets": "No targets yet. Add one below.",
|
||||
"settings.sync.third_party": "third-party",
|
||||
"settings.sync.your_server": "your server",
|
||||
"settings.sync.edit": "Edit",
|
||||
"settings.sync.remove": "Remove",
|
||||
"settings.sync.edit_target": "Edit target",
|
||||
"settings.sync.add_target": "Add a target",
|
||||
"settings.sync.webdav_hint": "WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable SeafDAV server-side.)",
|
||||
"settings.sync.name": "Name",
|
||||
"settings.sync.name_placeholder": "Home Nextcloud",
|
||||
"settings.sync.provider": "Provider",
|
||||
"settings.sync.server_url": "Server URL",
|
||||
"settings.sync.server_url_note_1": "Just your server URL — WhispAssist adds",
|
||||
"settings.sync.server_url_note_2": "automatically.",
|
||||
"settings.sync.remote_folder": "Remote folder",
|
||||
"settings.sync.username": "Username",
|
||||
"settings.sync.app_password": "App password",
|
||||
"settings.sync.pw_keep": "leave blank to keep current password",
|
||||
"settings.sync.pw_store": "stored in OS credential store",
|
||||
"settings.sync.upload_legend": "Upload",
|
||||
"settings.sync.artifact_transcript": "transcript",
|
||||
"settings.sync.artifact_notes": "notes",
|
||||
"settings.sync.artifact_summary": "summary",
|
||||
"settings.sync.artifact_recording": "recording (.wav, if retained)",
|
||||
"settings.sync.allow_plaintext": "Allow plaintext http for a LAN address (not recommended)",
|
||||
"settings.sync.encrypt_before": "Encrypt before upload (destination stores only ciphertext)",
|
||||
"settings.sync.requires_vault": "— requires an unlocked vault",
|
||||
"settings.sync.test_connection": "Test connection",
|
||||
"settings.sync.save_changes": "Save changes",
|
||||
"settings.sync.add_target_btn": "Add target",
|
||||
"settings.sync.cancel": "Cancel",
|
||||
"settings.sync.third_party_banner": "{kind} is a third-party cloud — uploading sends your data off your device to {kind}.",
|
||||
"settings.sync.link_account": "Link {kind} account…",
|
||||
"settings.sync.oauth_hint": "Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.",
|
||||
"settings.sync.footnote": "Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.",
|
||||
|
||||
"settings.ai.title": "AI summary provider",
|
||||
"settings.ai.intro_1": "Summaries run on a local LLM by default. Point this at Ollama on this PC or another machine on your LAN (e.g.",
|
||||
"settings.ai.intro_2": "— both count as local, so nothing leaves your network. Hosted providers (Anthropic) are optional, off by default, and send the transcript to a third party once you turn one on.",
|
||||
"settings.ai.provider": "Provider",
|
||||
"settings.ai.off": "Off",
|
||||
"settings.ai.provider_ollama": "Ollama (local / LAN)",
|
||||
"settings.ai.provider_custom": "Custom (OpenAI-compatible)",
|
||||
"settings.ai.provider_anthropic": "Anthropic (Claude) — hosted, leaves this device",
|
||||
"settings.ai.endpoint": "Endpoint",
|
||||
"settings.ai.model": "Model",
|
||||
"settings.ai.api_key": "API key",
|
||||
"settings.ai.api_key_set_placeholder": "•••••••••••••••• (already set — leave blank to keep it)",
|
||||
"settings.ai.show_api_key": "Show API key",
|
||||
"settings.ai.hide_api_key": "Hide API key",
|
||||
"settings.ai.anthropic_banner": "Anthropic is a hosted, third-party service — this meeting's transcript leaves your device when you generate a summary.",
|
||||
"settings.ai.endpoint_banner": "This endpoint isn't on your machine or LAN — your transcript would leave your network.",
|
||||
"settings.ai.saving": "Saving…",
|
||||
"settings.ai.save": "Save",
|
||||
"settings.ai.test_connection": "Test connection",
|
||||
"settings.ai.this_hosted_provider": "this hosted provider",
|
||||
"settings.ai.reachable": "Reachable",
|
||||
"settings.ai.unreachable": "Unreachable",
|
||||
"settings.ai.model_count_one": "· {n} model",
|
||||
"settings.ai.model_count_many": "· {n} models",
|
||||
"settings.ai.on_lan": "· on your machine / LAN",
|
||||
"settings.ai.leaves_network": "· leaves your network",
|
||||
"settings.ai.advanced_title": "Advanced Ollama configuration",
|
||||
"settings.ai.changed": "{n} changed",
|
||||
"settings.ai.reset_all": "Reset all to defaults",
|
||||
"settings.ai.system_prompt": "System prompt",
|
||||
"settings.ai.system_prompt_placeholder": "e.g. You are a concise meeting summarizer.",
|
||||
"settings.ai.system_prompt_hint": "Added before WhispAssist's required output format, so your instructions can't break summary/action-item parsing.",
|
||||
"settings.ai.think": "Think",
|
||||
"settings.ai.think_help": "Reasoning effort (reasoning models only).",
|
||||
"settings.ai.keep_alive": "Keep alive",
|
||||
"settings.ai.keep_alive_help": "How long the model stays in RAM. e.g. 5m, 1h, 0 (unload), -1 (forever).",
|
||||
"settings.ai.runtime_hardware": "Runtime & hardware",
|
||||
"settings.ai.rarely_needed": "— rarely needed",
|
||||
"settings.ai.save_advanced": "Save advanced",
|
||||
"settings.ai.saved": "Saved",
|
||||
"settings.ai.turn_off": "Turn off",
|
||||
"settings.ai.default_value": "Default {value}.",
|
||||
"settings.ai.reset_default": "Reset to default",
|
||||
"settings.ai.reset_field": "Reset {name}",
|
||||
|
||||
"settings.mcp.title": "MCP server",
|
||||
"settings.mcp.banner_1": "This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting context on your local machine. Once connected,",
|
||||
"settings.mcp.banner_agent": "that agent",
|
||||
"settings.mcp.banner_2": "may forward what it reads to its own model provider's cloud — outside WhispAssist's control. WhispAssist itself never sends this data anywhere; the server only listens on this device (",
|
||||
"settings.mcp.banner_3": ") and every read is logged below.",
|
||||
"settings.mcp.enable": "Enable the MCP server",
|
||||
"settings.mcp.enable_hint": "Off by default. Loopback-only, token-gated — nothing is reachable from the network.",
|
||||
"settings.mcp.transport": "Transport",
|
||||
"settings.mcp.transport_http": "Streamable HTTP",
|
||||
"settings.mcp.transport_stdio": "stdio (agent spawns a process)",
|
||||
"settings.mcp.port": "Port",
|
||||
"settings.mcp.transport_change_hint": "To change transport/port, turn the server off first, then back on.",
|
||||
"settings.mcp.scope_title": "Scope",
|
||||
"settings.mcp.expose": "Expose",
|
||||
"settings.mcp.scope_none": "None — nothing is shared",
|
||||
"settings.mcp.scope_selected": "Selected — only feature briefs you've marked shared",
|
||||
"settings.mcp.scope_all": "All — meetings, transcripts, action items, and shared briefs",
|
||||
"settings.mcp.expose_recordings": "Also allow meetings with a saved recording (off by default — a recorded meeting's transcript is withheld even in \"All\" scope until this is on)",
|
||||
"settings.mcp.token_title": "New auth token — shown once, copy it now",
|
||||
"settings.mcp.token_hint": "This won't be shown again. It's stored in your OS credential store; if you lose it, turn the server off and back on to mint a new one.",
|
||||
"settings.mcp.copied": "Copied",
|
||||
"settings.mcp.copy": "Copy",
|
||||
"settings.mcp.endpoint": "Endpoint",
|
||||
"settings.mcp.endpoint_hint": "Point your agent's MCP client config at this {kind}, with the token above as a bearer credential.",
|
||||
"settings.mcp.kind_command": "command",
|
||||
"settings.mcp.kind_url": "URL",
|
||||
"settings.mcp.access_log": "Access log",
|
||||
"settings.mcp.access_log_hint": "Every tool read an agent makes, allowed or denied (FR-MCP-5).",
|
||||
"settings.mcp.no_reads": "No agent has read anything yet.",
|
||||
"settings.mcp.log_meeting": "meeting {id}",
|
||||
"settings.mcp.refresh": "Refresh",
|
||||
|
||||
"settings.privacy.title": "Privacy",
|
||||
"settings.privacy.intro": "What WhispAssist is actually allowed to send off this device right now. With everything off (the default), nothing leaves the device at all.",
|
||||
"settings.privacy.llm_endpoint": "LLM endpoint",
|
||||
"settings.privacy.off": "off",
|
||||
"settings.privacy.local_only": "local-only",
|
||||
"settings.privacy.leaves_device": "leaves this device",
|
||||
"settings.privacy.sync_label": "Sync",
|
||||
"settings.privacy.enabled": "enabled",
|
||||
"settings.privacy.mcp_label": "MCP server",
|
||||
"settings.privacy.mcp_on": "on · {scope}",
|
||||
"settings.privacy.mcp_loopback_note": "Inbound on loopback only — it adds nothing to the egress list above. A connected agent may still forward what it reads to its own model provider; see the MCP server tab.",
|
||||
"settings.privacy.egress_title": "Egress allowlist",
|
||||
"settings.privacy.no_egress": "No hosts are allowlisted — WA makes no content egress.",
|
||||
"settings.privacy.sync_targets_title": "Sync targets",
|
||||
"settings.privacy.third_party": "third-party",
|
||||
"settings.privacy.your_server": "your server",
|
||||
"settings.privacy.tls": "TLS",
|
||||
"settings.privacy.no_tls": "no TLS",
|
||||
"settings.privacy.refresh": "Refresh",
|
||||
"settings.privacy.unavailable": "Privacy self-check unavailable.",
|
||||
"settings.privacy.vault_title": "Encryption vault",
|
||||
"settings.privacy.vault_intro": "Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are not encrypted yet.)",
|
||||
"settings.privacy.vault_password": "Vault password",
|
||||
"settings.privacy.enable_vault": "Enable vault",
|
||||
"settings.privacy.vault_pw_hint": "Use at least 8 characters. If you forget it, encrypted content can't be recovered.",
|
||||
"settings.privacy.vault_locked_1": "Vault is ",
|
||||
"settings.privacy.locked_word": "locked",
|
||||
"settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.",
|
||||
"settings.privacy.password": "Password",
|
||||
"settings.privacy.unlock": "Unlock",
|
||||
"settings.privacy.vault_unlocked_1": "Vault is ",
|
||||
"settings.privacy.unlocked_word": "unlocked",
|
||||
"settings.privacy.vault_unlocked_2": ". New notes, transcripts, and summaries are encrypted at rest.",
|
||||
"settings.privacy.lock_now": "Lock now",
|
||||
"settings.privacy.change_password": "Change password",
|
||||
"settings.privacy.current_password": "Current password",
|
||||
"settings.privacy.new_password": "New password",
|
||||
|
||||
"settings.about.title": "About",
|
||||
"settings.about.tagline": "A fully local, open-source, Windows-native meeting assistant.",
|
||||
"settings.about.build_commit": "Build commit",
|
||||
|
||||
"consent.heading": "Before you record",
|
||||
"consent.body": "Recording conversations without the consent of participants may be illegal in your region. Check your local recording laws. This is a caution, not legal advice.",
|
||||
"consent.accept": "I understand — enable recording",
|
||||
"consent.cancel": "Cancel",
|
||||
|
||||
"hosted.heading": "Before using {provider}",
|
||||
"hosted.body_1": "Generating with",
|
||||
"hosted.body_2": "sends this meeting's transcript to their servers — it leaves this device and is subject to their privacy policy. WhispAssist has no control over data handling once it leaves your device. This is off by default; you're choosing it now.",
|
||||
"hosted.accept": "I understand — continue",
|
||||
"hosted.cancel": "Cancel",
|
||||
|
||||
"theme.group": "Theme",
|
||||
"theme.system": "Match system",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
"import.dialog_label": "Add a meeting from a file or URL",
|
||||
"import.title": "Add a meeting",
|
||||
"import.close": "Close",
|
||||
"import.close_title": "Close (Esc)",
|
||||
"import.body": "Import an existing recording — a local audio/video file, or a link (YouTube, a streaming page, or a direct media URL). It's transcribed and diarized just like a live recording.",
|
||||
"import.file_or_url": "File or URL",
|
||||
"import.source_placeholder": "Paste a URL, or browse for a file…",
|
||||
"import.choose_file": "Choose a local file",
|
||||
"import.browse": "Browse…",
|
||||
"import.title_label": "Title",
|
||||
"import.optional": "optional",
|
||||
"import.title_placeholder": "Defaults to the file name",
|
||||
"import.filter_av": "Audio / video",
|
||||
"import.requires_1": "Requires",
|
||||
"import.requires_2": "installed and on your PATH (plus",
|
||||
"import.requires_3": "for URLs). WhispAssist doesn't bundle them.",
|
||||
"import.importing": "Importing… this can take a while",
|
||||
"import.import": "Import",
|
||||
"import.cancel": "Cancel",
|
||||
|
||||
"tagchip.filter": "Filter meetings tagged \"{tag}\"",
|
||||
"tagchip.remove": "Remove tag {tag}",
|
||||
|
||||
"levelmeter.system_mic": "System and microphone input level",
|
||||
"levelmeter.system": "System input level",
|
||||
|
||||
"app.tagline": "local · private",
|
||||
"app.note_template": "Note template",
|
||||
"app.no_template": "No template",
|
||||
"app.add_meeting": "Add meeting",
|
||||
"app.add_meeting_title": "Add a meeting from a file or URL",
|
||||
"app.record": "Record",
|
||||
"app.record_title": "Start recording (Ctrl+Shift+R)",
|
||||
"app.stop": "Stop",
|
||||
"app.stop_title": "Stop recording (Ctrl+Shift+R)",
|
||||
"app.cancel": "Cancel",
|
||||
"app.cancel_title": "Discard this recording and delete it",
|
||||
"app.recording": "Recording…",
|
||||
"app.backend_title": "Active transcription backend",
|
||||
"app.retention_title": "Save audio as .wav for this meeting",
|
||||
"app.saving": "saving",
|
||||
"app.not_saved": "not saved",
|
||||
"app.reconnecting": "reconnecting audio device…",
|
||||
"app.settings": "Settings",
|
||||
"app.settings_title": "Settings (Ctrl+,)",
|
||||
"app.discard_confirm": "Discard this recording? Its audio and transcript will be deleted.",
|
||||
"app.announce_started": "Recording started",
|
||||
"app.announce_paused": "Recording paused",
|
||||
"app.announce_stopped": "Recording stopped",
|
||||
"app.consent_dialog": "Recording consent",
|
||||
"app.vault_dialog": "Unlock encryption vault",
|
||||
"app.vault_title": "Unlock encryption vault",
|
||||
"app.vault_desc": "Your meetings are encrypted at rest. Enter your password to read them.",
|
||||
"app.vault_password": "Vault password",
|
||||
"app.vault_incorrect": "Incorrect password",
|
||||
"app.unlock": "Unlock",
|
||||
"app.continue_locked": "Continue locked",
|
||||
"app.show_meetings": "Show meetings list",
|
||||
"app.hide_meetings": "Hide meetings list",
|
||||
"app.show_summary": "Show summary panel",
|
||||
"app.hide_summary": "Hide summary panel",
|
||||
"app.resize_meetings": "Resize meetings list",
|
||||
"app.resize_summary": "Resize summary panel",
|
||||
|
||||
"meetings.search_placeholder": "Search meetings…",
|
||||
"meetings.search_aria": "Search meetings",
|
||||
"meetings.filter_tag_aria": "Filter by tag",
|
||||
"meetings.all_tags": "All tags",
|
||||
"meetings.from_date_aria": "From date",
|
||||
"meetings.to_date_aria": "To date",
|
||||
"meetings.bulk_format_aria": "Bulk export format",
|
||||
"meetings.bulk_export": "Bulk export",
|
||||
"meetings.bulk_export_title": "Export every meeting matching the tag/date filters above",
|
||||
"meetings.exporting": "Exporting…",
|
||||
"meetings.exported_one": "Exported {n} meeting.",
|
||||
"meetings.exported_many": "Exported {n} meetings.",
|
||||
"meetings.searching": "Searching…",
|
||||
"meetings.loading": "Loading meetings…",
|
||||
"meetings.no_matches": "No matches.",
|
||||
"meetings.empty": "No meetings yet — click Record above to start.",
|
||||
"meetings.status.recording": "recording",
|
||||
"meetings.status.transcribing": "transcribing",
|
||||
"meetings.status.recovering": "recovering",
|
||||
"meetings.status.error": "error",
|
||||
"meetings.resume": "Resume transcription",
|
||||
"meetings.delete_aria": "Delete meeting",
|
||||
"meetings.delete_confirm": "Delete this meeting? This removes its recording, transcript, and notes.",
|
||||
|
||||
"transcript.heading": "Transcript",
|
||||
"transcript.title_aria": "Meeting title",
|
||||
"transcript.lang_title": "Transcription language",
|
||||
"transcript.lang_auto": "auto-detecting…",
|
||||
"transcript.show": "Show transcript",
|
||||
"transcript.hide": "Hide transcript",
|
||||
"transcript.empty": "No transcript for this meeting.",
|
||||
"transcript.will_appear": "Transcript will appear here as you record.",
|
||||
"transcript.play_from_here": "Play from here",
|
||||
"transcript.resize": "Resize transcript and notes",
|
||||
"transcript.reprocess_placeholder": "Re-transcribe with…",
|
||||
"transcript.reprocess_lang_aria": "Reprocess language",
|
||||
"transcript.reprocess_keep_lang": "Keep current language",
|
||||
"transcript.reprocess_go": "Go",
|
||||
"transcript.retranscribing": "Re-transcribing…",
|
||||
"transcript.has_note": "Has a note",
|
||||
"transcript.note_placeholder": "Add a note for this moment…",
|
||||
"transcript.note_aria": "Note for this transcript line",
|
||||
"transcript.filter_md": "Markdown",
|
||||
"transcript.filter_pdf": "PDF",
|
||||
"transcript.filter_docx": "Word document",
|
||||
"transcript.filter_obsidian": "Obsidian note",
|
||||
|
||||
"notes.heading": "Notes",
|
||||
"notes.show": "Show notes",
|
||||
"notes.hide": "Hide notes",
|
||||
"notes.placeholder": "Notes…",
|
||||
"notes.live_placeholder": "Type notes while you talk…",
|
||||
"notes.toolbar_aria": "Notes formatting",
|
||||
"notes.bold": "Bold",
|
||||
"notes.italic": "Italic",
|
||||
"notes.h1": "Heading 1",
|
||||
"notes.h2": "Heading 2",
|
||||
"notes.bullet": "Bullet list",
|
||||
"notes.checkbox_title": "Checkbox",
|
||||
"notes.checkbox_aria": "Checkbox list item",
|
||||
"notes.edit_raw": "Edit the raw markdown",
|
||||
"notes.render": "Render the markdown",
|
||||
"notes.editor": "Editor",
|
||||
"notes.preview": "Preview",
|
||||
"notes.export_md_title": "Export notes as .md",
|
||||
"notes.export_pdf_title": "Export notes as .pdf",
|
||||
"notes.export_docx_title": "Export notes as .docx",
|
||||
"notes.export_bundle_title": "Export audio + transcript + notes to a folder",
|
||||
"notes.export_obsidian_title": "Export one Obsidian note (notes + summary + transcript, no audio)",
|
||||
|
||||
"summary.recording_heading": "Recording",
|
||||
"summary.loading_recording": "Loading recording…",
|
||||
"summary.sync_heading": "Sync",
|
||||
"summary.uploading": "Uploading…",
|
||||
"summary.upload_now": "Upload now",
|
||||
"summary.no_uploads": "No uploads yet for this meeting.",
|
||||
"summary.retry": "retry",
|
||||
"summary.tags_heading": "Tags",
|
||||
"summary.tags_select": "Select a meeting to tag it.",
|
||||
"summary.tag_add_placeholder": "Add tag…",
|
||||
"summary.tag_first_placeholder": "project, client, topic…",
|
||||
"summary.generating": "Generating…",
|
||||
"summary.generate_tags": "Generate tags",
|
||||
"summary.saving": "Saving…",
|
||||
"summary.save_tags": "Save tags",
|
||||
"summary.summary_heading": "Summary",
|
||||
"summary.ai_provider": "AI provider",
|
||||
"summary.provider_off": "Off",
|
||||
"summary.provider_custom": "Custom",
|
||||
"summary.local": "local",
|
||||
"summary.leaves_device": "leaves this device",
|
||||
"summary.this_hosted_provider": "this hosted provider",
|
||||
"summary.select_generate": "Select a meeting to generate a summary.",
|
||||
"summary.decisions_heading": "Decisions",
|
||||
"summary.regenerate": "Regenerate",
|
||||
"summary.generated_hint": "Generated locally after the meeting (requires a local LLM provider).",
|
||||
"summary.no_provider": "No AI provider configured — enable one in Settings.",
|
||||
"summary.generate_summary": "Generate summary",
|
||||
"summary.briefs_heading": "Feature briefs",
|
||||
"summary.briefs_select": "Select a meeting to create or view feature briefs.",
|
||||
"summary.briefs_desc": "Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a coding agent, or serve it over the MCP server once that's on.",
|
||||
"summary.brief_repo_placeholder": "Target repo (optional), e.g. acme/reporting-web",
|
||||
"summary.distilling": "Distilling…",
|
||||
"summary.create_brief": "Create feature brief",
|
||||
"summary.brief_mcp_title": "Available when the MCP server is on",
|
||||
"summary.copied": "Copied",
|
||||
"summary.copy_md": "Copy as Markdown",
|
||||
"summary.copy_json": "Copy as JSON",
|
||||
"summary.brief_problem": "Problem",
|
||||
"summary.brief_outcome": "Desired outcome",
|
||||
"summary.brief_criteria": "Acceptance criteria",
|
||||
"summary.brief_none": "None captured.",
|
||||
"summary.brief_context": "Context",
|
||||
"summary.action_items_heading": "Action items",
|
||||
"summary.ai_select": "Select a meeting to see its action items.",
|
||||
"summary.ai_empty": "None yet — add one below, or generate a summary to extract them automatically.",
|
||||
"summary.confirmed": "Confirmed",
|
||||
"summary.ai_text_placeholder": "Action item…",
|
||||
"summary.ai_text_aria": "Action item text",
|
||||
"summary.owner": "Owner",
|
||||
"summary.due_date": "Due date",
|
||||
"summary.reminder_title": "Schedule a local reminder for this due date",
|
||||
"summary.ai_delete_title": "Delete this action item",
|
||||
"summary.ai_delete_aria": "Delete action item",
|
||||
"summary.add_action_item": "Add action item",
|
||||
"summary.save_action_items": "Save action items",
|
||||
"summary.calendar_heading": "Calendar event",
|
||||
"summary.cal_select": "Select a meeting to link it to a calendar event.",
|
||||
"summary.event_search_placeholder": "Search event title…",
|
||||
"summary.linked_event": "Linked event",
|
||||
"summary.change_event": "Change event…",
|
||||
"summary.link_event": "Link an event…",
|
||||
"summary.untitled_event": "(untitled)",
|
||||
"summary.no_events_1": "No events imported yet — import a",
|
||||
"summary.no_events_2": "from Settings → Calendar.",
|
||||
"summary.no_events_match": "No events match this search/date — try clearing one.",
|
||||
"summary.organizer_label": "Organizer: {name}",
|
||||
"summary.participants_heading": "Participants",
|
||||
"summary.participants_hint": "Populated from the linked calendar event.",
|
||||
"summary.speakers_heading": "Speakers",
|
||||
"summary.speakers_select": "Select a meeting to name its speakers.",
|
||||
"summary.no_speakers": "No speakers detected yet.",
|
||||
"summary.speaker_name_placeholder": "Speaker name",
|
||||
"summary.save": "Save",
|
||||
"summary.cancel": "Cancel",
|
||||
"summary.name_speaker": "Name this speaker…",
|
||||
"summary.add_new_name": "+ Add new name…",
|
||||
|
||||
"settings.recording.title": "Recording",
|
||||
"settings.recording.record_default": "Record meetings by default",
|
||||
"settings.recording.save_audio_as": "save audio as",
|
||||
"settings.recording.default_hint": "Off by default. When off, audio is used only to produce the transcript and is deleted when the meeting is finalized. You can also toggle recording per meeting.",
|
||||
"settings.recording.consent_label": "Consent: {status}.",
|
||||
"settings.recording.consent_ack": "acknowledged",
|
||||
"settings.recording.consent_not": "not yet acknowledged",
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Minimal hand-rolled i18n — no dependency. A reactive `locale` (persisted to
|
||||
// localStorage, UI ephemera like the layout store) plus a `t(key)` lookup over
|
||||
// per-language JSON dictionaries. English is the fallback for any missing key,
|
||||
// so a partial translation degrades to English rather than showing raw keys.
|
||||
//
|
||||
// Adding a language: create `<code>.json` next to en.json, import it, and add
|
||||
// it to DICTS + LOCALES below. Translating that one file is the whole job.
|
||||
|
||||
import en from "./en.json";
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
// Register languages here. en is the source-of-truth key set + fallback.
|
||||
const DICTS: Record<string, Dict> = { en };
|
||||
export const LOCALES: { code: string; label: string }[] = [{ code: "en", label: "English" }];
|
||||
|
||||
const KEY = "wa-locale-v1";
|
||||
|
||||
function load(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved && saved in DICTS) return saved;
|
||||
} catch {
|
||||
/* localStorage unavailable — fall through to default */
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
class I18n {
|
||||
locale = $state(load());
|
||||
|
||||
setLocale(code: string) {
|
||||
if (!(code in DICTS)) return;
|
||||
this.locale = code;
|
||||
try {
|
||||
localStorage.setItem(KEY, code);
|
||||
} catch {
|
||||
/* non-fatal: preference just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up `key` in the active locale, falling back to English then the key
|
||||
* itself. `vars` fills `{name}` placeholders. Reads `locale` so components
|
||||
* that call `t()` in markup re-render when the language changes. */
|
||||
t = (key: string, vars?: Record<string, string | number>): string => {
|
||||
const dict = DICTS[this.locale] ?? en;
|
||||
let s = dict[key] ?? (en as Dict)[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
export const t = i18n.t;
|
||||
@@ -1,7 +1,7 @@
|
||||
// Imported calendar events (Phase 6, FR-CAL-*). Svelte 5 runes store, same
|
||||
// shape as settings.svelte.ts/meetings.svelte.ts.
|
||||
|
||||
import { api, events, type CalendarEvent } from "../api";
|
||||
import { api, errorMessage, events, type CalendarEvent } from "../api";
|
||||
|
||||
class CalendarStore {
|
||||
events = $state<CalendarEvent[]>([]);
|
||||
@@ -22,15 +22,15 @@ class CalendarStore {
|
||||
}
|
||||
}
|
||||
|
||||
async importPst(path: string, password?: string) {
|
||||
async importPst(path: string, password?: string, rangeDays?: number) {
|
||||
this.importing = true;
|
||||
this.importError = null;
|
||||
this.importProgress = null;
|
||||
try {
|
||||
await api.importPst(path, password);
|
||||
await api.importPst(path, password, rangeDays);
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this.importError = e instanceof Error ? e.message : String(e);
|
||||
this.importError = errorMessage(e);
|
||||
} finally {
|
||||
this.importing = false;
|
||||
this.importProgress = null;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Resizable/hideable pane preferences (FR-UX-1) — pure client-side UI
|
||||
// ephemera (not app data), so localStorage is the right place for it
|
||||
// rather than a round-trip through Tauri settings.
|
||||
|
||||
const KEY = "wa-layout-v1";
|
||||
|
||||
interface LayoutPrefs {
|
||||
leftWidth: number;
|
||||
rightWidth: number;
|
||||
leftCollapsed: boolean;
|
||||
rightCollapsed: boolean;
|
||||
/** Width of the transcript sub-pane vs notes, 0..1. */
|
||||
transcriptFraction: number;
|
||||
transcriptCollapsed: boolean;
|
||||
notesCollapsed: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: LayoutPrefs = {
|
||||
leftWidth: 260,
|
||||
rightWidth: 320,
|
||||
leftCollapsed: false,
|
||||
rightCollapsed: false,
|
||||
transcriptFraction: 0.5,
|
||||
transcriptCollapsed: false,
|
||||
notesCollapsed: false,
|
||||
};
|
||||
|
||||
function load(): LayoutPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return { ...DEFAULTS };
|
||||
return { ...DEFAULTS, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
class LayoutStore {
|
||||
leftWidth = $state(DEFAULTS.leftWidth);
|
||||
rightWidth = $state(DEFAULTS.rightWidth);
|
||||
leftCollapsed = $state(DEFAULTS.leftCollapsed);
|
||||
rightCollapsed = $state(DEFAULTS.rightCollapsed);
|
||||
transcriptFraction = $state(DEFAULTS.transcriptFraction);
|
||||
transcriptCollapsed = $state(DEFAULTS.transcriptCollapsed);
|
||||
notesCollapsed = $state(DEFAULTS.notesCollapsed);
|
||||
|
||||
constructor() {
|
||||
Object.assign(this, load());
|
||||
}
|
||||
|
||||
/** Call after any mutation — explicit rather than an $effect so a batch
|
||||
* of drag-resize updates doesn't schedule a write per pixel. */
|
||||
persist() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({
|
||||
leftWidth: this.leftWidth,
|
||||
rightWidth: this.rightWidth,
|
||||
leftCollapsed: this.leftCollapsed,
|
||||
rightCollapsed: this.rightCollapsed,
|
||||
transcriptFraction: this.transcriptFraction,
|
||||
transcriptCollapsed: this.transcriptCollapsed,
|
||||
notesCollapsed: this.notesCollapsed,
|
||||
} satisfies LayoutPrefs),
|
||||
);
|
||||
} catch {
|
||||
// localStorage unavailable (private mode etc.) — layout just won't persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const layout = new LayoutStore();
|
||||
@@ -169,6 +169,15 @@ class MeetingsStore {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
/** Click-to-filter from a tag chip anywhere (T8.3, FR-SEARCH-2) — same
|
||||
* mechanism as the sidebar's tag dropdown, just triggered elsewhere.
|
||||
* Keeps any existing date filter, drops search mode (a tag filter and a
|
||||
* text search are two different views over the same list). */
|
||||
async filterByTag(tag: string) {
|
||||
this.searchResults = null;
|
||||
await this.load({ ...this.filter, tag });
|
||||
}
|
||||
|
||||
/** `null` clears search mode and reverts the list view to `load()`'s results. */
|
||||
async search(query: string | null) {
|
||||
if (!query || !query.trim()) {
|
||||
@@ -209,9 +218,11 @@ class MeetingsStore {
|
||||
if (this.selectedId === id) await this.select(id);
|
||||
}
|
||||
|
||||
/** Batch re-transcribe with a different (typically larger) model (T3.8). */
|
||||
async reprocess(id: MeetingId, model: string) {
|
||||
await api.reprocessTranscript(id, model);
|
||||
/** Batch re-transcribe with a different (typically larger) model (T3.8).
|
||||
* `language` (T8.7, FR-TRX-4): omitted reuses the meeting's current
|
||||
* language rather than resetting it to auto. */
|
||||
async reprocess(id: MeetingId, model: string, language?: string) {
|
||||
await api.reprocessTranscript(id, model, language);
|
||||
await this.load();
|
||||
if (this.selectedId === id) await this.select(id);
|
||||
}
|
||||
@@ -224,7 +235,17 @@ class MeetingsStore {
|
||||
/** Link a recording to a calendar event (T6.3/T6.6, FR-CAL-2/4). */
|
||||
async attachEvent(id: MeetingId, eventId: string) {
|
||||
await api.attachMeetingToEvent(id, eventId);
|
||||
// Attaching mirrors the event's subject onto the title server-side
|
||||
// (FR-CAL-2) — refresh the list too, not just the detail view.
|
||||
if (this.selectedId === id) await this.select(id);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
/** Manual rename (T2.2) — recordings otherwise default to "Untitled meeting". */
|
||||
async renameMeeting(id: MeetingId, title: string) {
|
||||
await api.renameMeeting(id, title);
|
||||
if (this.selectedId === id) await this.select(id);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
/** Free-text speaker rename (T4.4, FR-SPK-2) — the "add new name" escape
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Shared recording-playback state so the transcript (center pane,
|
||||
// TranscriptNotes) and the <audio> element (side pane, SummaryPanel) can talk
|
||||
// to each other across the App layout: click a transcript segment to seek the
|
||||
// player, and highlight/auto-scroll the segment that's currently playing.
|
||||
// Pure UI ephemera — no persistence, reset per meeting.
|
||||
|
||||
class PlayerStore {
|
||||
/** Playback position in ms, pushed from the <audio> element's timeupdate. */
|
||||
currentMs = $state(0);
|
||||
/** Target of the latest seek request, in ms. */
|
||||
seekMs = $state<number | null>(null);
|
||||
/** Bumped on every seek() so clicking the *same* segment twice re-seeks
|
||||
* (a plain seekMs assignment wouldn't fire when the value is unchanged). */
|
||||
seekNonce = $state(0);
|
||||
|
||||
/** Ask the player to jump to `ms` and play (SummaryPanel watches seekNonce). */
|
||||
seek(ms: number) {
|
||||
this.seekMs = ms;
|
||||
this.seekNonce++;
|
||||
}
|
||||
|
||||
/** Clear when the selected meeting changes so a new recording starts at 0. */
|
||||
reset() {
|
||||
this.currentMs = 0;
|
||||
this.seekMs = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const player = new PlayerStore();
|
||||
@@ -2,6 +2,8 @@
|
||||
// Subscribes to recording/transcript events and exposes reactive state.
|
||||
|
||||
import { api, events, type TranscriptSegment, type MeetingId } from "../api";
|
||||
import { settings } from "./settings.svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
class RecordingStore {
|
||||
meetingId = $state<MeetingId | null>(null);
|
||||
@@ -10,20 +12,35 @@ class RecordingStore {
|
||||
segments = $state<TranscriptSegment[]>([]);
|
||||
/** Whether audio is being retained as .wav for the in-flight meeting (ADR-0009). */
|
||||
retention = $state(false);
|
||||
/** Live input level for the waveform/meter (FR-CAP-5); 0 when not recording. */
|
||||
/** Live system/loopback level for the waveform/meter (FR-CAP-5); 0 when not recording. */
|
||||
levelRms = $state(0);
|
||||
levelPeak = $state(0);
|
||||
/** Live microphone level, overlaid on the meter in a different colour
|
||||
* (FR-CAP-7); stays 0 when the mic is disabled or not recording. */
|
||||
levelRmsMic = $state(0);
|
||||
levelPeakMic = $state(0);
|
||||
/** Set while a capture-device reconnect is in progress; cleared on recovery (FR-CAP-6). */
|
||||
deviceNotice = $state<string | null>(null);
|
||||
/** Live notes redesign: freeform text typed in the Notes pane while recording. */
|
||||
notesText = $state("");
|
||||
/** anchor_ms (a segment's start_ms) -> note text, for moments annotated this recording. */
|
||||
segmentNotes = new SvelteMap<number, string>();
|
||||
private notesSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async init() {
|
||||
await events.onRecordingState((p) => {
|
||||
const e = p as { state: "recording" | "paused" | "stopped"; elapsedMs: number };
|
||||
this.state = e.state === "stopped" ? "idle" : e.state;
|
||||
const e = p as {
|
||||
state: "recording" | "paused" | "stopped" | "cancelled";
|
||||
elapsedMs: number;
|
||||
};
|
||||
const ended = e.state === "stopped" || e.state === "cancelled";
|
||||
this.state = ended ? "idle" : (e.state as "recording" | "paused");
|
||||
this.elapsedMs = e.elapsedMs ?? this.elapsedMs;
|
||||
if (e.state === "stopped") {
|
||||
if (ended) {
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
}
|
||||
});
|
||||
await events.onRetention((p) => {
|
||||
@@ -35,37 +52,106 @@ class RecordingStore {
|
||||
if (i >= 0) this.segments[i] = segment;
|
||||
else this.segments.push(segment);
|
||||
});
|
||||
await events.onLevel(({ rms, peak }) => {
|
||||
this.levelRms = rms;
|
||||
this.levelPeak = peak;
|
||||
await events.onLevel(({ rms, peak, mic }) => {
|
||||
if (mic) {
|
||||
this.levelRmsMic = rms;
|
||||
this.levelPeakMic = peak;
|
||||
} else {
|
||||
this.levelRms = rms;
|
||||
this.levelPeak = peak;
|
||||
}
|
||||
});
|
||||
await events.onDeviceChanged(({ recovered, message }) => {
|
||||
this.deviceNotice = recovered ? null : message;
|
||||
});
|
||||
}
|
||||
|
||||
async start(title?: string, record = false, templateId?: string) {
|
||||
async start(title?: string, record = false, templateId?: string, calendarEventId?: string) {
|
||||
this.segments = [];
|
||||
this.retention = record;
|
||||
this.deviceNotice = null;
|
||||
this.meetingId = await api.startRecording(title, undefined, record, templateId);
|
||||
this.notesText = "";
|
||||
this.segmentNotes.clear();
|
||||
// T8.7/FR-TRX-4: whatever language is currently configured in Settings
|
||||
// becomes this meeting's requested language, persisted on its record.
|
||||
const language = settings.settings.whisper_language ?? undefined;
|
||||
this.meetingId = await api.startRecording(title, calendarEventId, record, templateId, language);
|
||||
this.state = "recording";
|
||||
}
|
||||
|
||||
async stop() {
|
||||
// The debounced save below can lag up to 500ms behind typing — flush
|
||||
// whatever's pending first so stop_recording's merge sees the latest text.
|
||||
await this.flushNotes();
|
||||
if (this.meetingId) await api.stopRecording(this.meetingId);
|
||||
this.state = "idle";
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
this.deviceNotice = null;
|
||||
}
|
||||
|
||||
/** Abandon an accidental recording: stop capture and delete it entirely. */
|
||||
async cancel() {
|
||||
if (this.notesSaveTimer) {
|
||||
clearTimeout(this.notesSaveTimer);
|
||||
this.notesSaveTimer = null;
|
||||
}
|
||||
if (this.meetingId) await api.cancelRecording(this.meetingId);
|
||||
this.meetingId = null;
|
||||
this.segments = [];
|
||||
this.state = "idle";
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
this.levelRmsMic = 0;
|
||||
this.levelPeakMic = 0;
|
||||
this.deviceNotice = null;
|
||||
this.notesText = "";
|
||||
this.segmentNotes.clear();
|
||||
}
|
||||
|
||||
/** Toggle audio retention mid-meeting (FR-REC-1); caller must have gated consent already. */
|
||||
async setRetention(record: boolean) {
|
||||
if (!this.meetingId) return;
|
||||
await api.setRecordingRetention(this.meetingId, record);
|
||||
this.retention = record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live notes redesign: freeform text typed in the Notes pane while
|
||||
* recording. Debounced 500ms (same cadence as the post-finalize editor's
|
||||
* `scheduleSave`) so every keystroke doesn't round-trip to the backend.
|
||||
*/
|
||||
setNotesText(text: string) {
|
||||
this.notesText = text;
|
||||
if (!this.meetingId) return;
|
||||
const meetingId = this.meetingId;
|
||||
if (this.notesSaveTimer) clearTimeout(this.notesSaveTimer);
|
||||
this.notesSaveTimer = setTimeout(() => {
|
||||
this.notesSaveTimer = null;
|
||||
api.updateLiveNotes(meetingId, text).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private async flushNotes() {
|
||||
if (this.notesSaveTimer) {
|
||||
clearTimeout(this.notesSaveTimer);
|
||||
this.notesSaveTimer = null;
|
||||
}
|
||||
if (this.meetingId) {
|
||||
await api.updateLiveNotes(this.meetingId, this.notesText).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach (or clear, with `text: ""`) a note to a clicked transcript
|
||||
* segment's moment (the "click a transcript line, add a note" feature). */
|
||||
async setSegmentNote(anchorMs: number, text: string) {
|
||||
if (!this.meetingId) return;
|
||||
if (text.trim() === "") this.segmentNotes.delete(anchorMs);
|
||||
else this.segmentNotes.set(anchorMs, text);
|
||||
await api.setSegmentNote(this.meetingId, anchorMs, text);
|
||||
}
|
||||
}
|
||||
|
||||
export const recording = new RecordingStore();
|
||||
|
||||
@@ -8,12 +8,16 @@ import {
|
||||
errorMessage,
|
||||
events,
|
||||
type AppSettings,
|
||||
type AudioDeviceInfo,
|
||||
type SyncTargetInfo,
|
||||
type SyncTargetConfig,
|
||||
type HardwareStatus,
|
||||
type LlmStatus,
|
||||
type ModelInfo,
|
||||
type LanguageOption,
|
||||
type PrivacySelfCheck,
|
||||
type McpStatus,
|
||||
type McpAccessEntry,
|
||||
} from "../api";
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -24,13 +28,26 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
llm_model: "llama3",
|
||||
preferred_backend: "auto",
|
||||
whisper_model: "base.en-q5_1",
|
||||
whisper_language: null, // auto-detect by default (T8.7, FR-TRX-4)
|
||||
low_overhead: false,
|
||||
default_record: false, // recording OFF by default (ADR-0009)
|
||||
consent_acknowledged: false,
|
||||
hosted_ai_acknowledged: false, // hosted-AI "leaves your device" notice (ADR-0011)
|
||||
sync_enabled: false, // sync OFF by default (ADR-0010)
|
||||
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
|
||||
mcp_transport: "http",
|
||||
mcp_port: 4849,
|
||||
mcp_expose: "none", // scope OFF by default (FR-MCP-3)
|
||||
mcp_expose_recordings: false,
|
||||
retention_max_age_days: null, // no cap by default (FR-STORE-2)
|
||||
retention_max_size_gb: null,
|
||||
pst_last_path: null,
|
||||
pst_auto_sync: false,
|
||||
pst_import_range_days: null, // full mailbox history by default
|
||||
auto_record_calendar: false, // don't auto-start on calendar events by default
|
||||
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)
|
||||
audio_input_device: null, // system default capture device
|
||||
};
|
||||
|
||||
class SettingsStore {
|
||||
@@ -45,11 +62,28 @@ class SettingsStore {
|
||||
// Hardware + model management (Phase 3, T3.6/T3.7).
|
||||
hardware = $state<HardwareStatus | null>(null);
|
||||
models = $state<ModelInfo[]>([]);
|
||||
// Speaker-diarization models (segmentation + embedding). Both must be
|
||||
// installed before recordings separate speakers instead of labelling
|
||||
// everything "S1" (T4.7, FR-MODEL-1).
|
||||
diarizationModels = $state<ModelInfo[]>([]);
|
||||
// Transcription language catalog for the Settings dropdown (T8.7, FR-TRX-4).
|
||||
languages = $state<LanguageOption[]>([]);
|
||||
audioDevices = $state<AudioDeviceInfo[]>([]);
|
||||
inputDevices = $state<AudioDeviceInfo[]>([]);
|
||||
downloadProgress = $state<Record<string, { received: number; total: number | null }>>({});
|
||||
|
||||
// Privacy self-check (T7.6, FR-SEC-2).
|
||||
privacy = $state<PrivacySelfCheck | null>(null);
|
||||
|
||||
// MCP server (Phase 10b, ADR-0011).
|
||||
mcpStatus = $state<McpStatus | null>(null);
|
||||
mcpAccessLog = $state<McpAccessEntry[]>([]);
|
||||
mcpSaving = $state(false);
|
||||
/** The freshly-minted token from the last `setMcpEnabled(true)` call —
|
||||
* shown exactly once (it is never re-readable afterwards, same as any
|
||||
* other newly-issued secret). Cleared on disable or when the panel closes. */
|
||||
mcpLastToken = $state<string | null>(null);
|
||||
|
||||
// LLM provider status (T5.2, FR-LLM-1).
|
||||
llmStatus = $state<LlmStatus | null>(null);
|
||||
llmSaving = $state(false);
|
||||
@@ -69,9 +103,23 @@ class SettingsStore {
|
||||
this.backendStub = true;
|
||||
}
|
||||
await this.loadHardware();
|
||||
await this.loadAudioDevices();
|
||||
await this.loadInputDevices();
|
||||
await this.loadModels();
|
||||
await this.loadDiarizationModels();
|
||||
await this.loadLanguages();
|
||||
await this.loadPrivacy();
|
||||
await this.loadLlmStatus();
|
||||
await this.loadMcpStatus();
|
||||
await this.loadMcpAccessLog();
|
||||
// Live tail of the FR-MCP-5 audit log — every tool read an agent makes
|
||||
// while the panel is open shows up immediately, not just on refresh.
|
||||
await events.onMcpAccess(({ at, tool, meetingId, client }) => {
|
||||
this.mcpAccessLog = [
|
||||
{ at, tool, meeting_id: meetingId ?? null, client: client ?? null },
|
||||
...this.mcpAccessLog,
|
||||
].slice(0, 50);
|
||||
});
|
||||
await events.onHardwareChanged(({ active }) => {
|
||||
if (this.hardware) this.hardware.active = active;
|
||||
});
|
||||
@@ -123,6 +171,32 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
async loadAudioDevices() {
|
||||
try {
|
||||
this.audioDevices = await api.listAudioDevices();
|
||||
} catch {
|
||||
this.audioDevices = [];
|
||||
}
|
||||
}
|
||||
|
||||
async setAudioOutputDevice(deviceId: string | null) {
|
||||
await this.patch({ audio_output_device: deviceId });
|
||||
}
|
||||
|
||||
async loadInputDevices() {
|
||||
try {
|
||||
this.inputDevices = await api.listInputDevices();
|
||||
} catch {
|
||||
this.inputDevices = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the microphone selection in one patch (FR-CAP-7): `enabled=false`
|
||||
* disables mic capture entirely; `deviceId=null` uses the system default. */
|
||||
async setMicrophone(enabled: boolean, deviceId: string | null) {
|
||||
await this.patch({ microphone_enabled: enabled, audio_input_device: deviceId });
|
||||
}
|
||||
|
||||
async loadModels() {
|
||||
try {
|
||||
this.models = await api.listModels();
|
||||
@@ -131,6 +205,44 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
async loadDiarizationModels() {
|
||||
try {
|
||||
this.diarizationModels = await api.listDiarizationModels();
|
||||
} catch {
|
||||
this.diarizationModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Download one diarization model. The two known ids map to the backend's
|
||||
* `diar-seg`/`diar-emb` kinds; `removeModel` needs no kind (it disambiguates
|
||||
* by catalog membership). */
|
||||
async downloadDiarizationModel(id: string) {
|
||||
this.clearProgress(id);
|
||||
await api.downloadModel(id, id.startsWith("seg") ? "diar-seg" : "diar-emb");
|
||||
this.clearProgress(id);
|
||||
await this.loadDiarizationModels();
|
||||
}
|
||||
|
||||
async removeDiarizationModel(id: string) {
|
||||
await api.removeModel(id);
|
||||
await this.loadDiarizationModels();
|
||||
}
|
||||
|
||||
async loadLanguages() {
|
||||
try {
|
||||
this.languages = await api.listWhisperLanguages();
|
||||
} catch {
|
||||
this.languages = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` = auto-detect (T8.7, FR-TRX-4). Only meaningful when the active
|
||||
* model is multilingual — the Settings UI disables/hides this control
|
||||
* otherwise, and the backend forces "en" regardless if it's set anyway. */
|
||||
async setWhisperLanguage(code: string | null) {
|
||||
await this.patch({ whisper_language: code });
|
||||
}
|
||||
|
||||
async loadPrivacy() {
|
||||
try {
|
||||
this.privacy = await api.privacySelfCheck();
|
||||
@@ -139,6 +251,49 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
async loadMcpStatus() {
|
||||
try {
|
||||
this.mcpStatus = await api.mcpStatus();
|
||||
} catch {
|
||||
this.mcpStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMcpAccessLog(limit = 50) {
|
||||
try {
|
||||
this.mcpAccessLog = await api.mcpAccessLog(limit);
|
||||
} catch {
|
||||
this.mcpAccessLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable/disable the loopback MCP server (FR-MCP-1/6). On enable, the
|
||||
* returned token is stashed in `mcpLastToken` for the one-time reveal. */
|
||||
async setMcpEnabled(enabled: boolean, transport?: "http" | "stdio", port?: number) {
|
||||
this.mcpSaving = true;
|
||||
try {
|
||||
const res = await api.setMcpEnabled(enabled, transport, port);
|
||||
this.mcpLastToken = enabled ? res.token : null;
|
||||
} catch {
|
||||
this.backendStub = true;
|
||||
} finally {
|
||||
this.mcpSaving = false;
|
||||
}
|
||||
await this.loadMcpStatus();
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
/** Scope control (FR-MCP-3) — takes effect immediately, no restart needed. */
|
||||
async setMcpScope(expose: "none" | "selected" | "all", exposeRecordings?: boolean) {
|
||||
try {
|
||||
await api.setMcpScope(expose, exposeRecordings);
|
||||
} catch {
|
||||
this.backendStub = true;
|
||||
}
|
||||
await this.loadMcpStatus();
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
async loadLlmStatus() {
|
||||
try {
|
||||
this.llmStatus = await api.llmStatus();
|
||||
@@ -147,8 +302,16 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the LLM provider/endpoint/model and refresh status (T5.2). */
|
||||
async setLlmProvider(config: { provider: string; endpoint?: string; model?: string }) {
|
||||
/** Persist the LLM provider/endpoint/model (+ hosted apiKey, ADR-0011) and
|
||||
* refresh status (T5.2/T10.2). The key is only ever sent to the backend
|
||||
* command (which stores it in the OS credential store) — never held here
|
||||
* beyond this call, and never merged into `this.settings`. */
|
||||
async setLlmProvider(config: {
|
||||
provider: string;
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
}) {
|
||||
this.llmSaving = true;
|
||||
// Optimistic local update so the form reflects the change immediately.
|
||||
this.settings = {
|
||||
@@ -166,6 +329,13 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the one-time hosted-AI "leaves your device" acknowledgment
|
||||
* (ADR-0011, T10.3) — same generic patch() every other boolean setting
|
||||
* here uses (see setDefaultRecord below). */
|
||||
acknowledgeHostedAi() {
|
||||
return this.patch({ hosted_ai_acknowledged: true });
|
||||
}
|
||||
|
||||
async setPreferredBackend(backend: AppSettings["preferred_backend"]) {
|
||||
await this.patch({ preferred_backend: backend });
|
||||
await this.loadHardware();
|
||||
@@ -248,6 +418,19 @@ class SettingsStore {
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
async updateTarget(config: SyncTargetConfig & { id: string }) {
|
||||
try {
|
||||
const updated = await api.updateSyncTarget(config);
|
||||
this.targets = this.targets.map((t) => (t.id === config.id ? updated : t));
|
||||
} catch {
|
||||
this.backendStub = true;
|
||||
this.targets = this.targets.map((t) =>
|
||||
t.id === config.id ? { ...t, ...stubTarget(config), id: config.id } : t,
|
||||
);
|
||||
}
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
async removeTarget(id: string) {
|
||||
this.targets = this.targets.filter((t) => t.id !== id);
|
||||
try {
|
||||
@@ -297,6 +480,13 @@ function stubTarget(c: SyncTargetConfig): SyncTargetInfo {
|
||||
enabled: c.enabled ?? false,
|
||||
third_party: c.kind !== "webdav",
|
||||
host,
|
||||
upload_transcript: c.upload_transcript ?? true,
|
||||
upload_notes: c.upload_notes ?? true,
|
||||
upload_summary: c.upload_summary ?? true,
|
||||
upload_recording: c.upload_recording ?? false,
|
||||
trigger_on_finalize: c.trigger_on_finalize ?? true,
|
||||
allow_plaintext_lan: c.allow_plaintext_lan ?? false,
|
||||
encrypt_before_upload: c.encrypt_before_upload ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
import { api, type MeetingListItem, type SearchHit } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Search, Trash2, Download, RotateCcw } from "@lucide/svelte";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
|
||||
// Meeting status ("recording"/"transcribing"/"recovering"/"error") → its
|
||||
// localized badge label. The status value itself is app vocabulary, not user
|
||||
// content, so it gets keyed.
|
||||
const statusLabel = (s: string) => t(`meetings.status.${s}`);
|
||||
|
||||
let query = $state("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -17,7 +23,10 @@
|
||||
// Tag/date filters (T8.3, FR-SEARCH-2) apply to the plain list, not
|
||||
// full-text search — changing one drops out of search mode so the
|
||||
// filtered list is immediately visible rather than hidden behind results.
|
||||
let tagFilter = $state("");
|
||||
// Writable derived (not $state+$effect) so this dropdown also reflects a
|
||||
// tag filter triggered elsewhere (e.g. clicking a chip in the Tags panel),
|
||||
// while still being directly editable via bind:value below.
|
||||
let tagFilter = $derived(meetings.filter.tag ?? "");
|
||||
let fromFilter = $state("");
|
||||
let toFilter = $state("");
|
||||
|
||||
@@ -52,7 +61,10 @@
|
||||
from: toUnix(fromFilter),
|
||||
to: toUnix(toFilter),
|
||||
});
|
||||
bulkResult = `Exported ${count} meeting${count === 1 ? "" : "s"}.`;
|
||||
bulkResult =
|
||||
count === 1
|
||||
? t("meetings.exported_one", { n: count })
|
||||
: t("meetings.exported_many", { n: count });
|
||||
} finally {
|
||||
bulkExporting = false;
|
||||
}
|
||||
@@ -90,7 +102,7 @@
|
||||
|
||||
async function removeMeeting(e: Event, id: string) {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Delete this meeting? This removes its recording, transcript, and notes.")) return;
|
||||
if (!confirm(t("meetings.delete_confirm"))) return;
|
||||
await meetings.remove(id);
|
||||
}
|
||||
|
||||
@@ -104,24 +116,38 @@
|
||||
<div class="search-box">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
placeholder="Search meetings…"
|
||||
aria-label="Search meetings"
|
||||
placeholder={t("meetings.search_placeholder")}
|
||||
aria-label={t("meetings.search_aria")}
|
||||
bind:value={query}
|
||||
oninput={onSearchInput}
|
||||
/>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select aria-label="Filter by tag" bind:value={tagFilter} onchange={onFilterChange}>
|
||||
<option value="">All tags</option>
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}>{t}</option>
|
||||
<select
|
||||
aria-label={t("meetings.filter_tag_aria")}
|
||||
bind:value={tagFilter}
|
||||
onchange={onFilterChange}
|
||||
>
|
||||
<option value="">{t("meetings.all_tags")}</option>
|
||||
{#each meetings.allTags as tag (tag)}
|
||||
<option value={tag}>{tag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input type="date" aria-label="From date" bind:value={fromFilter} onchange={onFilterChange} />
|
||||
<input type="date" aria-label="To date" bind:value={toFilter} onchange={onFilterChange} />
|
||||
<input
|
||||
type="date"
|
||||
aria-label={t("meetings.from_date_aria")}
|
||||
bind:value={fromFilter}
|
||||
onchange={onFilterChange}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
aria-label={t("meetings.to_date_aria")}
|
||||
bind:value={toFilter}
|
||||
onchange={onFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select aria-label="Bulk export format" bind:value={bulkFormat}>
|
||||
<select aria-label={t("meetings.bulk_format_aria")} bind:value={bulkFormat}>
|
||||
<option value="md">.md</option>
|
||||
<option value="pdf">.pdf</option>
|
||||
<option value="docx">.docx</option>
|
||||
@@ -131,23 +157,23 @@
|
||||
class="bulk-btn"
|
||||
onclick={bulkExport}
|
||||
disabled={bulkExporting}
|
||||
title="Export every meeting matching the tag/date filters above"
|
||||
title={t("meetings.bulk_export_title")}
|
||||
>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{bulkExporting ? "Exporting…" : "Bulk export"}
|
||||
{bulkExporting ? t("meetings.exporting") : t("meetings.bulk_export")}
|
||||
</button>
|
||||
</div>
|
||||
{#if bulkResult}
|
||||
<p class="muted small">{bulkResult}</p>
|
||||
{/if}
|
||||
{#if meetings.searching}
|
||||
<p class="muted">Searching…</p>
|
||||
<p class="muted">{t("meetings.searching")}</p>
|
||||
{:else if displayItems.length === 0 && meetings.loading}
|
||||
<p class="muted">Loading meetings…</p>
|
||||
<p class="muted">{t("meetings.loading")}</p>
|
||||
{:else if displayItems.length === 0 && meetings.searchResults !== null}
|
||||
<p class="muted">No matches.</p>
|
||||
<p class="muted">{t("meetings.no_matches")}</p>
|
||||
{:else if displayItems.length === 0}
|
||||
<p class="muted">No meetings yet — click Record above to start.</p>
|
||||
<p class="muted">{t("meetings.empty")}</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each displayItems as m (m.id)}
|
||||
@@ -156,11 +182,11 @@
|
||||
<span class="row">
|
||||
<span class="title">{m.title}</span>
|
||||
{#if m.status === "recording" || m.status === "transcribing"}
|
||||
<span class="badge live">{m.status}</span>
|
||||
<span class="badge live">{statusLabel(m.status)}</span>
|
||||
{:else if m.status === "recovering"}
|
||||
<span class="badge recovering">recovering</span>
|
||||
<span class="badge recovering">{statusLabel("recovering")}</span>
|
||||
{:else if m.status === "error"}
|
||||
<span class="badge error">error</span>
|
||||
<span class="badge error">{statusLabel("error")}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="row muted small">
|
||||
@@ -172,8 +198,8 @@
|
||||
{/if}
|
||||
{#if m.tags.length > 0}
|
||||
<span class="row tags">
|
||||
{#each m.tags as t (t)}
|
||||
<span class="chip">{t}</span>
|
||||
{#each m.tags as tag (tag)}
|
||||
<span class="chip">{tag}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -182,14 +208,14 @@
|
||||
{#if m.status === "recovering"}
|
||||
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
Resume transcription
|
||||
{t("meetings.resume")}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="link danger"
|
||||
onclick={(e) => removeMeeting(e, m.id)}
|
||||
aria-label="Delete meeting"
|
||||
title="Delete meeting"
|
||||
aria-label={t("meetings.delete_aria")}
|
||||
title={t("meetings.delete_aria")}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
+1286
-268
File diff suppressed because it is too large
Load Diff
+899
-101
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,13 @@
|
||||
import { recording } from "../stores/recording.svelte";
|
||||
import { meetings } from "../stores/meetings.svelte";
|
||||
import { settings } from "../stores/settings.svelte";
|
||||
import { player } from "../stores/player.svelte";
|
||||
import { api, type SpeakerInfo } from "../api";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
import { layout, clamp } from "../stores/layout.svelte";
|
||||
import Splitter from "../components/Splitter.svelte";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -21,17 +25,84 @@
|
||||
RefreshCw,
|
||||
MessageSquareText,
|
||||
NotebookPen,
|
||||
Eye,
|
||||
Pencil,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
|
||||
return speakers.find((s) => s.label === label)?.display_name ?? label;
|
||||
}
|
||||
|
||||
// Transcript timestamps: segment start_ms → "m:ss" (or "h:mm:ss" past an
|
||||
// hour). Shown as a quiet monospace prefix so a line reads "0:42 Alice: …".
|
||||
function fmtTs(ms: number): string {
|
||||
const total = Math.floor(ms / 1000);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const mm = h ? String(m).padStart(2, "0") : String(m);
|
||||
return `${h ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// T8.7/FR-TRX-4: the meeting view shows the language actually used, not
|
||||
// just the raw ISO code — falls back to the code itself if it's not in
|
||||
// the (curated) catalog, and to "auto-detecting…" before any is known.
|
||||
function languageLabel(code: string | null): string {
|
||||
if (!code) return t("transcript.lang_auto");
|
||||
return settings.languages.find((l) => l.code === code)?.label ?? code;
|
||||
}
|
||||
|
||||
let notesText = $state("");
|
||||
// Notes is a single pane: raw markdown ("Editor") or the rendered result
|
||||
// ("Preview"), toggled by one button whose label flips to the other mode.
|
||||
// Defaults to Preview: the merged notes.md is now a properly sectioned
|
||||
// document (## Notes / ## Transcript), not worth reading as raw markdown
|
||||
// by default the way the old single-speaker-blob output arguably was.
|
||||
let notesPreview = $state(true);
|
||||
let editorEl: HTMLTextAreaElement | undefined = $state();
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let loadedForId: string | null = null;
|
||||
|
||||
// Transcript/notes split (FR-UX-1): resizable (drag the Splitter) and
|
||||
// each side independently hideable, shared across the finalized-meeting
|
||||
// and live-recording views via the layout store. `splitWidth` tracks the
|
||||
// container's current pixel width (bound below) so a drag delta in
|
||||
// pixels can be converted to a fraction of the available space.
|
||||
let splitWidth = $state(600);
|
||||
function splitColumns(): string {
|
||||
if (layout.transcriptCollapsed) return "auto auto 1fr";
|
||||
if (layout.notesCollapsed) return "1fr auto auto";
|
||||
return `${layout.transcriptFraction * 100}% auto ${(1 - layout.transcriptFraction) * 100}%`;
|
||||
}
|
||||
function onSplitResize(deltaPx: number) {
|
||||
if (splitWidth <= 0) return;
|
||||
layout.transcriptFraction = clamp(layout.transcriptFraction + deltaPx / splitWidth, 0.2, 0.8);
|
||||
}
|
||||
function toggleTranscript() {
|
||||
const wouldCollapse = !layout.transcriptCollapsed;
|
||||
if (wouldCollapse && layout.notesCollapsed) return; // never hide both
|
||||
layout.transcriptCollapsed = wouldCollapse;
|
||||
layout.persist();
|
||||
}
|
||||
function toggleNotes() {
|
||||
const wouldCollapse = !layout.notesCollapsed;
|
||||
if (wouldCollapse && layout.transcriptCollapsed) return;
|
||||
layout.notesCollapsed = wouldCollapse;
|
||||
layout.persist();
|
||||
}
|
||||
|
||||
// Live-recording transcript: which segment (by start_ms, the note anchor —
|
||||
// see recording.svelte.ts) is showing its note-entry field, if any.
|
||||
let selectedSegmentMs = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
void recording.meetingId; // dependency: reset the open note field for a new recording
|
||||
selectedSegmentMs = null;
|
||||
});
|
||||
|
||||
// Sync the editor buffer whenever a different meeting is selected.
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
@@ -43,6 +114,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Recordings default to "Untitled meeting" (T2.2) — this is the only
|
||||
// rename affordance, since nothing else in the UI shows the title at all.
|
||||
async function onTitleChange(e: Event) {
|
||||
const m = meetings.selected;
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
if (!m) return;
|
||||
if (!value) {
|
||||
(e.target as HTMLInputElement).value = m.title; // revert an empty edit
|
||||
return;
|
||||
}
|
||||
if (value !== m.title) await meetings.renameMeeting(m.id, value);
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
const id = meetings.selected?.id;
|
||||
if (!id) return;
|
||||
@@ -79,7 +163,7 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.md`,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
filters: [{ name: t("transcript.filter_md"), extensions: ["md"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "md");
|
||||
}
|
||||
@@ -96,7 +180,7 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.pdf`,
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
filters: [{ name: t("transcript.filter_pdf"), extensions: ["pdf"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "pdf");
|
||||
}
|
||||
@@ -106,19 +190,63 @@
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.docx`,
|
||||
filters: [{ name: "Word document", extensions: ["docx"] }],
|
||||
filters: [{ name: t("transcript.filter_docx"), extensions: ["docx"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "docx");
|
||||
}
|
||||
|
||||
// Export a single self-contained Obsidian note (frontmatter + notes + summary
|
||||
// + action items + timestamped transcript, no audio) — save it into a vault
|
||||
// folder from the dialog. FR-STORE-4 sibling of the bundle export.
|
||||
async function exportObsidian() {
|
||||
const m = meetings.selected;
|
||||
if (!m) return;
|
||||
const path = await save({
|
||||
defaultPath: `${m.title}.md`,
|
||||
filters: [{ name: t("transcript.filter_obsidian"), extensions: ["md"] }],
|
||||
});
|
||||
if (path) await api.exportMeeting(m.id, path, "obsidian");
|
||||
}
|
||||
|
||||
// FR-REC-5: the finalized segment currently playing (greatest start_ms at or
|
||||
// before the playhead), for highlight + auto-scroll. currentMs ticks ~4x/s
|
||||
// but this only changes value at a segment boundary, so the effect below is
|
||||
// quiet between boundaries.
|
||||
const activeSegId = $derived.by(() => {
|
||||
const ms = player.currentMs;
|
||||
let id: number | null = null;
|
||||
for (const s of meetings.selected?.segments ?? []) {
|
||||
if (s.start_ms <= ms) id = s.id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
// Auto-scroll the playing segment into view — unless the user scrolled the
|
||||
// transcript by hand recently, so playback doesn't yank them back.
|
||||
// ponytail: 4s manual-scroll grace; widen if it still feels grabby.
|
||||
let transcriptEl = $state<HTMLElement | null>(null);
|
||||
let lastManualScroll = 0;
|
||||
$effect(() => {
|
||||
const id = activeSegId;
|
||||
if (id == null || !transcriptEl) return;
|
||||
if (Date.now() - lastManualScroll < 4000) return;
|
||||
transcriptEl
|
||||
.querySelector<HTMLElement>(".seg.active")
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
|
||||
let reprocessModel = $state("");
|
||||
// T8.7/FR-TRX-4: "" reuses the meeting's current language (backend default
|
||||
// when `language` is omitted) rather than resetting it to auto.
|
||||
let reprocessLanguage = $state("");
|
||||
let reprocessing = $state(false);
|
||||
async function reprocess() {
|
||||
const m = meetings.selected;
|
||||
if (!m || !reprocessModel) return;
|
||||
reprocessing = true;
|
||||
try {
|
||||
await meetings.reprocess(m.id, reprocessModel);
|
||||
await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined);
|
||||
} finally {
|
||||
reprocessing = false;
|
||||
}
|
||||
@@ -128,100 +256,336 @@
|
||||
<div class="wrap">
|
||||
{#if meetings.selected}
|
||||
{@const m = meetings.selected}
|
||||
<div class="split">
|
||||
<div class="pane transcript">
|
||||
<h4><MessageSquareText size={14} aria-hidden="true" /> Transcript</h4>
|
||||
{#if m.recorded && settings.models.some((mo) => mo.installed)}
|
||||
<div class="reprocess">
|
||||
<select bind:value={reprocessModel}>
|
||||
<option value="">Re-transcribe with…</option>
|
||||
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
|
||||
<option value={mo.id}>{mo.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
|
||||
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
|
||||
{reprocessing ? "Re-transcribing…" : "Go"}
|
||||
<input
|
||||
class="meeting-title"
|
||||
value={m.title}
|
||||
onchange={onTitleChange}
|
||||
aria-label={t("transcript.title_aria")}
|
||||
/>
|
||||
<div
|
||||
class="split"
|
||||
style="grid-template-columns: {splitColumns()};"
|
||||
bind:clientWidth={splitWidth}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -- wheel/touchmove
|
||||
here only note "the user scrolled by hand" to pause playback
|
||||
auto-scroll; the pane isn't an interactive control. -->
|
||||
<div
|
||||
class="pane transcript"
|
||||
class:collapsed={layout.transcriptCollapsed}
|
||||
bind:this={transcriptEl}
|
||||
onwheel={() => (lastManualScroll = Date.now())}
|
||||
ontouchmove={() => (lastManualScroll = Date.now())}
|
||||
>
|
||||
{#if layout.transcriptCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.show")}
|
||||
aria-label={t("transcript.show")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<MessageSquareText size={14} aria-hidden="true" />
|
||||
{t("transcript.heading")}
|
||||
<span class="badge lang" title={t("transcript.lang_title")}
|
||||
>{languageLabel(m.language)}</span
|
||||
>
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.hide")}
|
||||
aria-label={t("transcript.hide")}
|
||||
>
|
||||
<PanelLeftClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
{#if m.recorded && settings.models.some((mo) => mo.installed)}
|
||||
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
|
||||
<div class="reprocess">
|
||||
<select bind:value={reprocessModel}>
|
||||
<option value="">{t("transcript.reprocess_placeholder")}</option>
|
||||
{#each settings.models.filter((mo) => mo.installed) as mo (mo.id)}
|
||||
<option value={mo.id}>{mo.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if reprocessModelInfo?.multilingual}
|
||||
<select
|
||||
bind:value={reprocessLanguage}
|
||||
aria-label={t("transcript.reprocess_lang_aria")}
|
||||
>
|
||||
<option value="">{t("transcript.reprocess_keep_lang")}</option>
|
||||
<option value="auto">{t("settings.transcription.auto")}</option>
|
||||
{#each settings.languages as l (l.code)}
|
||||
<option value={l.code}>{l.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
|
||||
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
|
||||
{reprocessing ? t("transcript.retranscribing") : t("transcript.reprocess_go")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if m.segments.length === 0}
|
||||
<p class="muted">{t("transcript.empty")}</p>
|
||||
{:else}
|
||||
{#each m.segments as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg"
|
||||
class:active={s.id === activeSegId}
|
||||
onclick={() => player.seek(s.start_ms)}
|
||||
title={t("transcript.play_from_here")}
|
||||
>
|
||||
<span class="ts">{fmtTs(s.start_ms)}</span>
|
||||
<strong>{speakerName(s.speaker, m.speakers)}:</strong>
|
||||
{s.text}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
|
||||
<Splitter
|
||||
label={t("transcript.resize")}
|
||||
onResize={onSplitResize}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="pane notes" class:collapsed={layout.notesCollapsed}>
|
||||
{#if layout.notesCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.show")}
|
||||
aria-label={t("notes.show")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<NotebookPen size={14} aria-hidden="true" />
|
||||
{t("notes.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.hide")}
|
||||
aria-label={t("notes.hide")}
|
||||
>
|
||||
<PanelRightClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
<div class="toolbar" role="toolbar" aria-label={t("notes.toolbar_aria")}>
|
||||
<button
|
||||
onclick={() => wrapSelection("**")}
|
||||
title={t("notes.bold")}
|
||||
aria-label={t("notes.bold")}
|
||||
>
|
||||
<Bold size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => wrapSelection("_")}
|
||||
title={t("notes.italic")}
|
||||
aria-label={t("notes.italic")}
|
||||
>
|
||||
<Italic size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("# ")}
|
||||
title={t("notes.h1")}
|
||||
aria-label={t("notes.h1")}
|
||||
>
|
||||
<Heading1 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("## ")}
|
||||
title={t("notes.h2")}
|
||||
aria-label={t("notes.h2")}
|
||||
>
|
||||
<Heading2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- ")}
|
||||
title={t("notes.bullet")}
|
||||
aria-label={t("notes.bullet")}
|
||||
>
|
||||
<List size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- [ ] ")}
|
||||
title={t("notes.checkbox_title")}
|
||||
aria-label={t("notes.checkbox_aria")}
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={() => (notesPreview = !notesPreview)}
|
||||
title={notesPreview ? t("notes.edit_raw") : t("notes.render")}
|
||||
aria-pressed={notesPreview}
|
||||
>
|
||||
{#if notesPreview}
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
{t("notes.editor")}
|
||||
{:else}
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
{t("notes.preview")}
|
||||
{/if}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title={t("notes.export_md_title")}>
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
.md
|
||||
</button>
|
||||
<button onclick={exportPdf} title={t("notes.export_pdf_title")}>
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
PDF
|
||||
</button>
|
||||
<button onclick={exportDocx} title={t("notes.export_docx_title")}>
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
Word
|
||||
</button>
|
||||
<button onclick={exportBundle} title={t("notes.export_bundle_title")}>
|
||||
<FolderOutput size={13} aria-hidden="true" />
|
||||
Bundle
|
||||
</button>
|
||||
<button onclick={exportObsidian} title={t("notes.export_obsidian_title")}>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
Obsidian
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-preview">
|
||||
{#if notesPreview}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="preview">{@html renderMarkdown(notesText)}</div>
|
||||
{:else}
|
||||
<textarea
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder={t("notes.placeholder")}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if m.segments.length === 0}
|
||||
<p class="muted">No transcript for this meeting.</p>
|
||||
{:else}
|
||||
{#each m.segments as s (s.id)}
|
||||
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="pane notes">
|
||||
<h4><NotebookPen size={14} aria-hidden="true" /> Notes</h4>
|
||||
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
|
||||
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold">
|
||||
<Bold size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic">
|
||||
<Italic size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => insertLinePrefix("# ")} title="Heading 1" aria-label="Heading 1">
|
||||
<Heading1 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => insertLinePrefix("## ")} title="Heading 2" aria-label="Heading 2">
|
||||
<Heading2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- ")}
|
||||
title="Bullet list"
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- [ ] ")}
|
||||
title="Checkbox"
|
||||
aria-label="Checkbox list item"
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title="Export notes as .md">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
.md
|
||||
</button>
|
||||
<button onclick={exportPdf} title="Export notes as .pdf">
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
PDF
|
||||
</button>
|
||||
<button onclick={exportDocx} title="Export notes as .docx">
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
Word
|
||||
</button>
|
||||
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
|
||||
<FolderOutput size={13} aria-hidden="true" />
|
||||
Bundle
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-preview">
|
||||
<textarea
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder="Notes…"
|
||||
></textarea>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="preview">{@html renderMarkdown(notesText)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if recording.segments.length === 0}
|
||||
<p class="muted pad">Transcript will appear here as you record.</p>
|
||||
{:else if recording.state === "idle"}
|
||||
<p class="muted pad">{t("transcript.will_appear")}</p>
|
||||
{:else}
|
||||
<div class="pad">
|
||||
{#each recording.segments as s (s.id)}
|
||||
<p class:interim={s.interim}>
|
||||
<strong>{speakerName(s.speaker)}:</strong>
|
||||
{s.text}
|
||||
</p>
|
||||
{/each}
|
||||
<!-- Granola-style redesign: the Notes pane is open and typable while
|
||||
recording, and clicking a transcript line attaches a note to that
|
||||
moment — both merged into notes.md with the transcript at stop. -->
|
||||
<div
|
||||
class="split"
|
||||
style="grid-template-columns: {splitColumns()};"
|
||||
bind:clientWidth={splitWidth}
|
||||
>
|
||||
<div class="pane transcript" class:collapsed={layout.transcriptCollapsed}>
|
||||
{#if layout.transcriptCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.show")}
|
||||
aria-label={t("transcript.show")}
|
||||
>
|
||||
<PanelLeftOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<MessageSquareText size={14} aria-hidden="true" />
|
||||
{t("transcript.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleTranscript}
|
||||
title={t("transcript.hide")}
|
||||
aria-label={t("transcript.hide")}
|
||||
>
|
||||
<PanelLeftClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
{#if recording.segments.length === 0}
|
||||
<p class="muted">{t("transcript.will_appear")}</p>
|
||||
{:else}
|
||||
{#each recording.segments as s (s.id)}
|
||||
{@const hasNote = recording.segmentNotes.has(s.start_ms)}
|
||||
{@const open = selectedSegmentMs === s.start_ms}
|
||||
<div class="segment">
|
||||
<button
|
||||
type="button"
|
||||
class="segment-line"
|
||||
class:interim={s.interim}
|
||||
class:active={open}
|
||||
onclick={() => (selectedSegmentMs = open ? null : s.start_ms)}
|
||||
>
|
||||
<span class="ts">{fmtTs(s.start_ms)}</span>
|
||||
<strong>{speakerName(s.speaker)}:</strong>
|
||||
{s.text}
|
||||
{#if hasNote}
|
||||
<span class="note-badge" title={t("transcript.has_note")}>📝</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
<input
|
||||
type="text"
|
||||
class="segment-note-input"
|
||||
placeholder={t("transcript.note_placeholder")}
|
||||
value={recording.segmentNotes.get(s.start_ms) ?? ""}
|
||||
onchange={(e) =>
|
||||
recording.setSegmentNote(s.start_ms, (e.target as HTMLInputElement).value)}
|
||||
aria-label={t("transcript.note_aria")}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !layout.transcriptCollapsed && !layout.notesCollapsed}
|
||||
<Splitter
|
||||
label={t("transcript.resize")}
|
||||
onResize={onSplitResize}
|
||||
onResizeEnd={() => layout.persist()}
|
||||
/>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
<div class="pane notes" class:collapsed={layout.notesCollapsed}>
|
||||
{#if layout.notesCollapsed}
|
||||
<button
|
||||
class="pane-toggle"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.show")}
|
||||
aria-label={t("notes.show")}
|
||||
>
|
||||
<PanelRightOpen size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{:else}
|
||||
<h4>
|
||||
<NotebookPen size={14} aria-hidden="true" />
|
||||
{t("notes.heading")}
|
||||
<button
|
||||
class="pane-toggle inline"
|
||||
onclick={toggleNotes}
|
||||
title={t("notes.hide")}
|
||||
aria-label={t("notes.hide")}
|
||||
>
|
||||
<PanelRightClose size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</h4>
|
||||
<textarea
|
||||
class="live-notes"
|
||||
value={recording.notesText}
|
||||
oninput={(e) => recording.setNotesText((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder={t("notes.live_placeholder")}
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -229,6 +593,22 @@
|
||||
<style>
|
||||
.wrap {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.meeting-title {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
color: inherit;
|
||||
}
|
||||
.meeting-title:hover,
|
||||
.meeting-title:focus {
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
}
|
||||
.pad {
|
||||
padding: 1rem;
|
||||
@@ -246,10 +626,105 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.segment {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.segment-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.segment-line:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.segment-line.active {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.segment-line.interim {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
}
|
||||
.note-badge {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
/* Finalized-transcript line: a click-to-seek button styled to read as plain
|
||||
transcript text, highlighted while it's the segment currently playing. */
|
||||
.seg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.seg:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.seg.active {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
/* Transcript timestamp: a quiet monospace prefix, not competing with the
|
||||
speaker name or text for attention. */
|
||||
.ts {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.78em;
|
||||
color: var(--muted);
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
.segment-note-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0.2rem 0 0.4rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.live-notes {
|
||||
resize: none;
|
||||
width: 100%;
|
||||
height: calc(100% - 2rem);
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.live-notes:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: 100%;
|
||||
/* grid-template-columns set inline — depends on resize/collapse state (FR-UX-1). */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.pane {
|
||||
overflow: auto;
|
||||
@@ -259,6 +734,12 @@
|
||||
.pane.transcript {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.pane.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.pane h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -270,6 +751,39 @@
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pane-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.pane-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.pane-toggle.inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Transcription language (T8.7, FR-TRX-4) — a quiet pill, not a status
|
||||
color, since "which language" isn't a good/bad state to flag. */
|
||||
.badge.lang {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
.reprocess {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
@@ -330,11 +844,14 @@
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
height: calc(100% - 2.5rem);
|
||||
}
|
||||
.toolbar .toggle {
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar .toggle[aria-pressed="true"] {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
textarea {
|
||||
resize: none;
|
||||
width: 100%;
|
||||
@@ -352,8 +869,12 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.preview {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
// The WebView2 default right-click menu (Back/Forward/Reload/Inspect) doesn't
|
||||
// belong in a native-feeling desktop app — disabled app-wide until/unless a
|
||||
// WhispAssist-specific context menu replaces it (see project memory).
|
||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app;
|
||||
|
||||
Reference in New Issue
Block a user