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:
2026-07-03 11:42:34 -05:00
11 changed files with 404 additions and 40 deletions
+4
View File
@@ -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
View File
@@ -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",
+39
View File
@@ -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.
+52 -2
View File
@@ -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"
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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[]) =>
+35 -1
View File
@@ -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();
+113 -8
View File
@@ -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>