diff --git a/.gitignore b/.gitignore
index 2113534..352527e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,7 @@ Thumbs.db
.env
.env.*
!.env.example
+
+# 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
diff --git a/package.json b/package.json
index 749ef6f..5025e34 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "whispassist",
"private": true,
- "version": "0.1.0",
+ "version": "0.1.2",
"type": "module",
"description": "Privacy-first, fully local Windows meeting assistant.",
"license": "MIT OR Apache-2.0",
diff --git a/packaging/npu-runtime/README.md b/packaging/npu-runtime/README.md
new file mode 100644
index 0000000..3aa8420
--- /dev/null
+++ b/packaging/npu-runtime/README.md
@@ -0,0 +1,39 @@
+# NPU runtime bundle
+
+`whispassist-npu-runtime-win-x64.zip` is the on-demand OpenVINO runtime for the
+NPU transcription path (T3.4). It is **not committed to git** (it's a ~40 MB
+binary) — it's hosted as a Gitea generic package and downloaded by the app on
+demand when an NPU is detected.
+
+## Contents
+The 22 flat DLLs of the validated, version-pinned runtime:
+
+- **ONNX Runtime 1.24.1** with the OpenVINO execution provider
+ (`onnxruntime.dll`, `onnxruntime_providers_openvino.dll`,
+ `onnxruntime_providers_shared.dll`)
+- **OpenVINO 2025.4.1** runtime + NPU plugin + TBB (`openvino.dll`,
+ `openvino_intel_npu_plugin.dll`, `tbb12.dll`, …)
+
+> ⚠️ The ORT↔OpenVINO versions are pinned. A mismatch makes ORT silently fall
+> back to CPU. Regenerate the bundle from a matched pip install
+> (`onnxruntime-openvino==1.24.1` + `openvino==2025.4.1`) if you bump either.
+
+## Current bundle
+
+- **SHA-256:** `c60de07b5b1ddc2fd1e966d8275d9f55ec261efc81355ea814d6dac897adbdc5`
+
+This hash is pinned in `src-tauri/src/commands.rs` (`NPU_RUNTIME_SHA256`); the
+app verifies the download against it. If you regenerate the zip, update that
+constant.
+
+## Hosting
+
+Upload this zip as a Gitea generic package, e.g.:
+
+```
+PUT https://git.dou.bet/api/packages/iamdoubz/generic/npu-runtime/2025.4.1/whispassist-npu-runtime-win-x64.zip
+```
+
+Then set the resulting download URL in `NPU_RUNTIME_URL`
+(`src-tauri/src/commands.rs`). It's also overridable at runtime without a
+rebuild via the `WA_NPU_RUNTIME_URL` environment variable.
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index f94bb37..b881792 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -63,6 +63,15 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
[[package]]
name = "argon2"
version = "0.5.3"
@@ -795,6 +804,17 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -928,7 +948,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
- "zip",
+ "zip 0.6.6",
]
[[package]]
@@ -5925,7 +5945,7 @@ dependencies = [
[[package]]
name = "whispassist"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"argon2",
"async-trait",
@@ -5958,6 +5978,7 @@ dependencies = [
"whisper-rs",
"windows 0.58.0",
"zeroize",
+ "zip 2.4.2",
]
[[package]]
@@ -6852,12 +6873,41 @@ dependencies = [
"flate2",
]
+[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "crossbeam-utils",
+ "displaydoc",
+ "flate2",
+ "indexmap 2.14.0",
+ "memchr",
+ "thiserror 2.0.18",
+ "zopfli",
+]
+
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
[[package]]
name = "zune-core"
version = "0.5.1"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index f53d0f5..00260d8 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "whispassist"
-version = "0.1.0"
+version = "0.1.2"
description = "Privacy-first, fully local Windows meeting assistant"
authors = ["WhispAssist contributors"]
license = "MIT OR Apache-2.0"
@@ -64,6 +64,7 @@ tauri-plugin-dialog = "2" # native Save/choose
# conversion service, consistent with the fully-local invariant.
pulldown-cmark = "0.12"
printpdf = "0.7"
+zip = { version = "2", default-features = false, features = ["deflate"] } # unzip the on-demand NPU runtime bundle (T3.4)
docx-rs = "0.4"
[target.'cfg(windows)'.dependencies]
@@ -81,7 +82,11 @@ windows = { version = "0.58", features = [
wasapi = { version = "0.15", optional = true } # Phase 1
[features]
-default = ["audio", "cpu-transcription", "diarization", "pst", "sync"]
+# `npu` ships in the default build: `ort` uses load-dynamic (no build-time
+# linking/OpenVINO needed), and the on-demand runtime/model download only works
+# if this code path is actually compiled in. NPU stays inert until an NPU is
+# detected AND its runtime is downloaded, so shipping it is safe (NFR-MNT-4).
+default = ["audio", "cpu-transcription", "diarization", "pst", "sync", "npu"]
# Phase 1
audio = ["dep:wasapi", "dep:hound"]
cpu-transcription = ["dep:whisper-rs"] # whisper-rs CPU build
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index af496c0..ca69f18 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -884,13 +884,20 @@ pub async fn download_npu_model(app: AppHandle) -> WaResult<()> {
.map_err(|e| WaError::new("npu", e.to_string()))
}
-/// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir.
-///
-/// ponytail: sourced by copying DLLs from local dirs named in
-/// `WA_NPU_RUNTIME_SRC` (';'-separated) — the same act a bundled installer step
-/// would perform. Upgrade path: host a versioned runtime bundle and
-/// download+unzip it here instead. Until a source is configured this is a clean
-/// typed error, never a panic.
+/// Hosted OpenVINO runtime bundle (ORT 1.24.1 + OpenVINO 2025.4.1 DLLs, zipped).
+/// Overridable at runtime via `WA_NPU_RUNTIME_URL`. Keep the SHA-256 in step with
+/// the uploaded bundle (see `dist/whispassist-npu-runtime-win-x64.zip`).
+#[cfg(feature = "npu")]
+const NPU_RUNTIME_URL: &str =
+ "https://git.dou.bet/api/packages/iamdoubz/generic/npu-runtime/2025.4.1/whispassist-npu-runtime-win-x64.zip";
+/// SHA-256 of the runtime bundle; empty string disables the integrity check.
+#[cfg(feature = "npu")]
+const NPU_RUNTIME_SHA256: &str = "c60de07b5b1ddc2fd1e966d8275d9f55ec261efc81355ea814d6dac897adbdc5";
+
+/// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir by
+/// downloading the hosted bundle and unzipping it (T3.4). `WA_NPU_RUNTIME_SRC`
+/// (';'-separated dirs) is honored as a dev/offline override that copies local
+/// DLLs instead of downloading.
#[cfg(feature = "npu")]
async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> {
if crate::paths::npu_runtime_ready() {
@@ -898,14 +905,24 @@ async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> {
}
let dir = crate::paths::npu_runtime_dir();
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("npu", e.to_string()))?;
- let src = std::env::var_os("WA_NPU_RUNTIME_SRC").ok_or_else(|| {
- WaError::new(
- "npu",
- "NPU runtime source not configured (set WA_NPU_RUNTIME_SRC to the ORT+OpenVINO DLL dir[s])",
- )
- })?;
+
+ // Dev/offline override: copy DLLs from local dirs instead of downloading.
+ if let Some(src) = std::env::var_os("WA_NPU_RUNTIME_SRC") {
+ return stage_npu_runtime_from_local(app, &dir, &src);
+ }
+
+ let url = std::env::var("WA_NPU_RUNTIME_URL").unwrap_or_else(|_| NPU_RUNTIME_URL.to_string());
+ download_and_extract_runtime(app, &dir, &url).await
+}
+
+#[cfg(feature = "npu")]
+fn stage_npu_runtime_from_local(
+ app: &AppHandle,
+ dir: &Path,
+ src: &std::ffi::OsStr,
+) -> WaResult<()> {
let mut copied = 0u32;
- for d in std::env::split_paths(&src) {
+ for d in std::env::split_paths(src) {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
@@ -933,6 +950,89 @@ async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> {
Ok(())
}
+/// Downloads the runtime bundle (streaming progress + SHA-256 check) and unzips
+/// its DLLs flat into `dir`.
+#[cfg(feature = "npu")]
+async fn download_and_extract_runtime(app: &AppHandle, dir: &Path, url: &str) -> WaResult<()> {
+ use futures_util::StreamExt;
+ use sha2::{Digest, Sha256};
+
+ let resp = reqwest::get(url)
+ .await
+ .map_err(|e| WaError::new("npu", e.to_string()))?;
+ if !resp.status().is_success() {
+ return Err(WaError::new(
+ "npu",
+ format!("runtime download failed: HTTP {}", resp.status()),
+ ));
+ }
+ let total = resp.content_length();
+ let tmp = dir.join("runtime.zip.part");
+ let mut file = std::fs::File::create(&tmp).map_err(|e| WaError::new("npu", e.to_string()))?;
+ let mut hasher = Sha256::new();
+ let mut received = 0u64;
+ let mut stream = resp.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|e| WaError::new("npu", e.to_string()))?;
+ std::io::Write::write_all(&mut file, &chunk)
+ .map_err(|e| WaError::new("npu", e.to_string()))?;
+ hasher.update(&chunk);
+ received += chunk.len() as u64;
+ let _ = app.emit(
+ "npu://download",
+ serde_json::json!({ "stage": "runtime", "received": received, "total": total }),
+ );
+ }
+ drop(file);
+
+ let digest = format!("{:x}", hasher.finalize());
+ if !NPU_RUNTIME_SHA256.is_empty() && digest != NPU_RUNTIME_SHA256 {
+ let _ = std::fs::remove_file(&tmp);
+ return Err(WaError::new("npu", "runtime bundle checksum mismatch"));
+ }
+
+ // Unzip off the async runtime (CPU/IO-bound).
+ let tmp_for_unzip = tmp.clone();
+ let dir_for_unzip = dir.to_path_buf();
+ tokio::task::spawn_blocking(move || extract_zip_flat(&tmp_for_unzip, &dir_for_unzip))
+ .await
+ .map_err(|e| WaError::new("npu", e.to_string()))?
+ .map_err(|e| WaError::new("npu", e))?;
+ let _ = std::fs::remove_file(&tmp);
+
+ if !crate::paths::npu_runtime_ready() {
+ return Err(WaError::new(
+ "npu",
+ "runtime bundle extracted but onnxruntime.dll is missing",
+ ));
+ }
+ Ok(())
+}
+
+/// Extract every file entry of a zip into `dir`, flattening paths to just the
+/// file name (which also prevents zip-slip path traversal).
+#[cfg(feature = "npu")]
+fn extract_zip_flat(zip_path: &Path, dir: &Path) -> Result<(), String> {
+ let file = std::fs::File::open(zip_path).map_err(|e| e.to_string())?;
+ let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
+ for i in 0..archive.len() {
+ let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
+ if entry.is_dir() {
+ continue;
+ }
+ let Some(name) = Path::new(entry.name())
+ .file_name()
+ .and_then(|n| n.to_str())
+ .map(str::to_string)
+ else {
+ continue;
+ };
+ let mut out = std::fs::File::create(dir.join(name)).map_err(|e| e.to_string())?;
+ std::io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
/// Downloads everything the NPU engine needs (ONNX model + OpenVINO runtime) for
/// the Settings ▸ Hardware "download NPU package" action (T3.4 step 2).
#[tauri::command]
diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs
index 2362724..ad2fa0c 100644
--- a/src-tauri/src/llm/mod.rs
+++ b/src-tauri/src/llm/mod.rs
@@ -1,6 +1,8 @@
-//! Local LLM integration (Phase 5, FR-LLM-*). Ollama HTTP on localhost
-//! (ADR-0007). The ONLY network egress WA originates for content, and it must be
-//! local — `is_local` gates a "data leaves WA" warning for remote endpoints.
+//! Local LLM integration (Phase 5, FR-LLM-*). Ollama HTTP on localhost or a
+//! self-hosted box on your LAN (ADR-0007). The ONLY network egress WA originates
+//! for content, and it must stay on your own machine/network — `is_local`
+//! (loopback + private LAN) gates a "data leaves your network" warning for
+//! genuinely remote/internet endpoints.
use async_trait::async_trait;
use futures_util::StreamExt;
@@ -151,22 +153,38 @@ fn bullet_text(line: &str) -> Option
+ Summaries run on a local LLM. Point this at Ollama on this PC or another machine on your
+ LAN (e.g. 192.168.0.x) — both count as local, so nothing leaves your network.
+
Models: {settings.llmStatus.models.slice(0, 6).join(", ")}
+ {/if} + {:else} +