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 { } } -fn is_loopback_host(host: &str) -> bool { - if host.eq_ignore_ascii_case("localhost") { +/// Whether a host is on the user's own machine or private LAN — i.e. not the +/// public internet or a third party. Loopback and RFC-1918 / link-local ranges +/// (e.g. `192.168.0.0/24`, `10.0.0.0/8`) plus `*.local` count as local, so a +/// self-hosted Ollama on another box on your LAN is treated like localhost +/// (ADR-0007: the invariant is "no content leaves your control", not literally +/// "only 127.0.0.1"). A bare, non-`.local` hostname is *not* assumed local, +/// since it could resolve anywhere. +fn is_local_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().ends_with(".local") { return true; } // `Url::host_str()` keeps the brackets around an IPv6 literal (e.g. "[::1]"), // which `IpAddr`'s parser rejects — strip them before parsing. let host = host.trim_start_matches('[').trim_end_matches(']'); - host.parse::() - .map(|ip| ip.is_loopback()) - .unwrap_or(false) + match host.parse::() { + Ok(std::net::IpAddr::V4(ip)) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + Ok(std::net::IpAddr::V6(ip)) => { + // `is_unique_local`/`is_unicast_link_local` are MSRV 1.84; do it via + // octets to stay on the declared 1.77 baseline. + let o = ip.octets(); + ip.is_loopback() + || (o[0] & 0xfe) == 0xfc // fc00::/7 unique-local + || (o[0] == 0xfe && (o[1] & 0xc0) == 0x80) // fe80::/10 link-local + } + Err(_) => false, + } } fn is_local_endpoint(endpoint: &str) -> bool { reqwest::Url::parse(endpoint) .ok() - .and_then(|u| u.host_str().map(is_loopback_host)) + .and_then(|u| u.host_str().map(is_local_host)) .unwrap_or(false) } @@ -593,11 +611,20 @@ mod tests { } #[test] - fn is_local_endpoint_accepts_loopback_forms_and_rejects_remote_hosts() { + fn is_local_endpoint_accepts_loopback_and_lan_but_rejects_public_hosts() { + // Loopback. assert!(is_local_endpoint("http://localhost:11434")); assert!(is_local_endpoint("http://127.0.0.1:11434")); assert!(is_local_endpoint("http://[::1]:11434")); + // Private LAN (the point of this change). + assert!(is_local_endpoint("http://192.168.0.42:11434")); + assert!(is_local_endpoint("http://10.0.0.5:11434")); + assert!(is_local_endpoint("http://172.16.3.9:11434")); + assert!(is_local_endpoint("http://ollama.local:11434")); + // Public / third-party is still not local. assert!(!is_local_endpoint("https://api.example.com")); + assert!(!is_local_endpoint("http://8.8.8.8:11434")); + assert!(!is_local_endpoint("http://ollama-server:11434")); // bare hostname assert!(!is_local_endpoint("not a url")); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index baf212d..9d3f913 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WhispAssist", - "version": "0.1.0", + "version": "0.1.2", "identifier": "bet.dou.whispassist", "build": { "frontendDist": "../dist", diff --git a/src/lib/api.ts b/src/lib/api.ts index 18baabf..58d9361 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -365,7 +365,7 @@ export const api = { endpoint?: string; model?: string; apiKey?: string; - }) => invoke("set_llm_provider", { config }), + }) => invoke("set_llm_provider", { args: config }), generateSummary: (meetingId: MeetingId, templateId?: string) => invoke("generate_summary", { meetingId, templateId }), confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) => diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index 23efffd..cf6134b 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -5,11 +5,13 @@ import { api, + errorMessage, events, type AppSettings, type SyncTargetInfo, type SyncTargetConfig, type HardwareStatus, + type LlmStatus, type ModelInfo, type PrivacySelfCheck, } from "../api"; @@ -48,6 +50,10 @@ class SettingsStore { // Privacy self-check (T7.6, FR-SEC-2). privacy = $state(null); + // LLM provider status (T5.2, FR-LLM-1). + llmStatus = $state(null); + llmSaving = $state(false); + async load() { try { this.settings = await api.getSettings(); @@ -65,6 +71,7 @@ class SettingsStore { await this.loadHardware(); await this.loadModels(); await this.loadPrivacy(); + await this.loadLlmStatus(); await events.onHardwareChanged(({ active }) => { if (this.hardware) this.hardware.active = active; }); @@ -104,7 +111,7 @@ class SettingsStore { window.open(authUrl, "_blank"); this.linkMessage = "Finish sign-in in your browser…"; } catch (e) { - this.linkMessage = `Link failed: ${e}`; + this.linkMessage = `Link failed: ${errorMessage(e)}`; } } @@ -132,6 +139,33 @@ class SettingsStore { } } + async loadLlmStatus() { + try { + this.llmStatus = await api.llmStatus(); + } catch { + this.llmStatus = null; + } + } + + /** Persist the LLM provider/endpoint/model and refresh status (T5.2). */ + async setLlmProvider(config: { provider: string; endpoint?: string; model?: string }) { + this.llmSaving = true; + // Optimistic local update so the form reflects the change immediately. + this.settings = { + ...this.settings, + llm_provider: config.provider, + llm_endpoint: config.endpoint ?? this.settings.llm_endpoint, + llm_model: config.model ?? this.settings.llm_model, + }; + try { + await api.setLlmProvider(config); + await this.loadLlmStatus(); + await this.loadPrivacy(); + } finally { + this.llmSaving = false; + } + } + async setPreferredBackend(backend: AppSettings["preferred_backend"]) { await this.patch({ preferred_backend: backend }); await this.loadHardware(); diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 3ccf708..194543b 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -7,7 +7,7 @@ import { calendar } from "../stores/calendar.svelte"; import ConsentNotice from "../components/ConsentNotice.svelte"; import { open } from "@tauri-apps/plugin-dialog"; - import { api, events } from "../api"; + import { api, errorMessage, events } from "../api"; import type { BackendId, SyncKind, SyncTargetConfig } from "../api"; import { trapFocus } from "../actions/trapFocus"; import { @@ -20,13 +20,53 @@ CalendarDays, UploadCloud, ShieldCheck, + Sparkles, } from "@lucide/svelte"; let { onClose }: { onClose: () => void } = $props(); - let section = $state<"recording" | "hardware" | "storage" | "calendar" | "sync" | "privacy">( - "recording", - ); + let section = $state< + "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy" + >("recording"); + + // ---- AI summary provider (T5.2, FR-LLM-1) ---- + let llmProvider = $state(settings.settings.llm_provider); + let llmEndpoint = $state(settings.settings.llm_endpoint); + let llmModel = $state(settings.settings.llm_model); + $effect(() => { + // Re-sync the form when settings (re)load or are saved elsewhere. + llmProvider = settings.settings.llm_provider; + llmEndpoint = settings.settings.llm_endpoint; + llmModel = settings.settings.llm_model; + }); + async function saveLlm() { + await settings.setLlmProvider({ + provider: llmProvider, + endpoint: llmEndpoint || undefined, + model: llmModel || undefined, + }); + } + /** Client-side mirror of the backend's loopback+private-LAN check, for a live + * "leaves your network" hint while typing. */ + function endpointIsLocalOrLan(url: string): boolean { + try { + const h = new URL(url).hostname.replace(/^\[|\]$/g, ""); + if (h === "localhost" || h.toLowerCase().endsWith(".local") || h === "::1") return true; + const m = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!m) return false; + const a = Number(m[1]); + const b = Number(m[2]); + return ( + a === 127 || + a === 10 || + (a === 192 && b === 168) || + (a === 172 && b >= 16 && b <= 31) || + (a === 169 && b === 254) + ); + } catch { + return false; + } + } // ---- Calendar / .pst import (T6.1/T6.2/T6.3, FR-CAL-1/2) ---- let pstPath = $state(""); @@ -84,7 +124,7 @@ vaultMsg = "Vault enabled and unlocked."; await loadVault(); } catch (e) { - vaultMsg = `${e}`; + vaultMsg = errorMessage(e); } } async function unlockVault() { @@ -95,7 +135,7 @@ vaultMsg = "Unlocked."; await loadVault(); } catch (e) { - vaultMsg = `${e}`; + vaultMsg = errorMessage(e); } } async function lockVault() { @@ -111,7 +151,7 @@ vaultPw2 = ""; vaultMsg = "Password changed."; } catch (e) { - vaultMsg = `${e}`; + vaultMsg = errorMessage(e); } } @@ -126,7 +166,7 @@ await settings.loadHardware(); npuMsg = "NPU package ready."; } catch (e) { - npuMsg = `Failed: ${e}`; + npuMsg = `Failed: ${errorMessage(e)}`; } finally { npuBusy = false; } @@ -246,6 +286,9 @@ + @@ -620,6 +663,68 @@ TLS is required for non-LAN targets.

+ {:else if section === "ai"} +
+

AI summary provider

+

+ 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. +

+ + {#if llmProvider !== "off"} +
+ + +
+ {#if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)} + + {/if} +
+ + + {#if settings.llmStatus} + + {#if settings.llmStatus.reachable} + {/if} +
+ {#if settings.llmStatus?.reachable && settings.llmStatus.models.length} +

Models: {settings.llmStatus.models.slice(0, 6).join(", ")}

+ {/if} + {:else} +
+ +
+ {/if} +
{:else}

Privacy