Merge pull request 'Feature cleanup' (#12) from feature_cleanup into main
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
Generated
+52
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+114
-14
@@ -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]
|
||||
|
||||
+37
-10
@@ -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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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::<std::net::IpAddr>()
|
||||
.map(|ip| ip.is_loopback())
|
||||
.unwrap_or(false)
|
||||
match host.parse::<std::net::IpAddr>() {
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -365,7 +365,7 @@ export const api = {
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
}) => invoke<void>("set_llm_provider", { config }),
|
||||
}) => invoke<void>("set_llm_provider", { args: config }),
|
||||
generateSummary: (meetingId: MeetingId, templateId?: string) =>
|
||||
invoke<void>("generate_summary", { meetingId, templateId }),
|
||||
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
|
||||
|
||||
@@ -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<PrivacySelfCheck | null>(null);
|
||||
|
||||
// LLM provider status (T5.2, FR-LLM-1).
|
||||
llmStatus = $state<LlmStatus | null>(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();
|
||||
|
||||
@@ -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 @@
|
||||
<button class:active={section === "sync"} onclick={() => (section = "sync")}>
|
||||
<UploadCloud size={14} aria-hidden="true" /> Sync
|
||||
</button>
|
||||
<button class:active={section === "ai"} onclick={() => (section = "ai")}>
|
||||
<Sparkles size={14} aria-hidden="true" /> AI
|
||||
</button>
|
||||
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}>
|
||||
<ShieldCheck size={14} aria-hidden="true" /> Privacy
|
||||
</button>
|
||||
@@ -620,6 +663,68 @@
|
||||
TLS is required for non-LAN targets.
|
||||
</p>
|
||||
</section>
|
||||
{:else if section === "ai"}
|
||||
<section>
|
||||
<h3>AI summary provider</h3>
|
||||
<p class="muted">
|
||||
Summaries run on a local LLM. Point this at Ollama on this PC or another machine on your
|
||||
LAN (e.g. <code>192.168.0.x</code>) — both count as local, so nothing leaves your network.
|
||||
</p>
|
||||
<label
|
||||
>Provider
|
||||
<select bind:value={llmProvider}>
|
||||
<option value="off">Off</option>
|
||||
<option value="ollama">Ollama (local / LAN)</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if llmProvider !== "off"}
|
||||
<div class="grid">
|
||||
<label class="wide"
|
||||
>Endpoint<input
|
||||
bind:value={llmEndpoint}
|
||||
placeholder="http://192.168.0.42:11434"
|
||||
/></label
|
||||
>
|
||||
<label>Model<input bind:value={llmModel} placeholder="llama3.1" /></label>
|
||||
</div>
|
||||
{#if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)}
|
||||
<div class="banner">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
This endpoint isn't on your machine or LAN — your transcript would leave your network.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}>
|
||||
{settings.llmSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button onclick={() => settings.loadLlmStatus()}>Test / refresh</button>
|
||||
{#if settings.llmStatus}
|
||||
<span
|
||||
class="test"
|
||||
class:ok={settings.llmStatus.reachable}
|
||||
class:fail={!settings.llmStatus.reachable}
|
||||
>
|
||||
{#if settings.llmStatus.reachable}<Check size={14} aria-hidden="true" />{:else}<X
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
{settings.llmStatus.reachable ? "reachable" : "unreachable"}
|
||||
{#if settings.llmStatus.reachable && !settings.llmStatus.isLocal}· leaves your
|
||||
network{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if settings.llmStatus?.reachable && settings.llmStatus.models.length}
|
||||
<p class="muted">Models: {settings.llmStatus.models.slice(0, 6).join(", ")}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}>Turn off</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section>
|
||||
<h3>Privacy</h3>
|
||||
|
||||
Reference in New Issue
Block a user