diff --git a/PRIVACY.md b/PRIVACY.md index fdb25a1..cbd2993 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -39,6 +39,9 @@ computer only**. This **never includes PDF content**: local output paths, so you can see and reopen recent results. - **Last output folder** — so the next save defaults to a sensible location. - **Theme** — your light/dark preference. +- **Imported model files** — optional local model blobs you import from a + file on this computer, stored under the app data folder. They never + leave the machine. None of this is transmitted anywhere. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 227957a..6afc9ce 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2854,6 +2854,7 @@ dependencies = [ "lopdf", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1fba0fa..57a4650 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,9 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Content-addressed model checksums. +sha2 = "0.10" + # Cross-platform available-disk-space query. fs2 = "0.4" diff --git a/src-tauri/src/ai/lifecycle.rs b/src-tauri/src/ai/lifecycle.rs new file mode 100644 index 0000000..7c45d6f --- /dev/null +++ b/src-tauri/src/ai/lifecycle.rs @@ -0,0 +1,155 @@ +//! Opt-in model lifecycle helpers. Injected store root; no HTTP. +//! +//! Preview hashes a user-chosen path and does not write. Import is a second +//! explicit call. Load requires a ready store entry. + +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::error::AppError; + +use super::store::{ModelManifest, ModelStore}; +use super::{BackendStatus, InferenceBackend}; + +const LICENSE_PLACEHOLDER: &str = "imported"; +const RUNTIME_COMPAT_PLACEHOLDER: &str = "any"; +const HARDWARE_STUB: &str = "Hardware check is not available in this version."; + +/// Facts shown before an explicit import. Preview must not create blobs. +#[derive(Debug, Clone)] +pub struct ModelPreview { + pub size: u64, + pub sha256: String, + pub license: String, + pub runtime_compat: String, + pub location: PathBuf, + pub compatibility: String, +} + +/// Disk bytes recovered by deleting one checksum (blob size, at least). +#[derive(Debug, Clone)] +pub struct ModelRemoveResult { + pub checksum: String, + pub recovered_bytes: u64, +} + +/// Store-on-disk plus in-memory backend status. Restart does not auto-load. +#[derive(Debug, Clone)] +pub struct ModelSetupSnapshot { + pub ready: Vec, + pub backend_status: BackendStatus, +} + +/// Hash `path` and report the store location it would occupy. Does not write. +pub fn preview(root: &Path, path: &Path) -> Result { + let (size, sha256) = sha256_file(path)?; + Ok(ModelPreview { + size, + location: root.join("blobs").join(&sha256), + sha256, + license: LICENSE_PLACEHOLDER.to_string(), + runtime_compat: RUNTIME_COMPAT_PLACEHOLDER.to_string(), + compatibility: format!("{RUNTIME_COMPAT_PLACEHOLDER}. {HARDWARE_STUB}"), + }) +} + +/// Copy `path` into the store after the caller confirms `expected_sha256`. +pub fn import(root: &Path, path: &Path, expected_sha256: &str) -> Result { + let store = ModelStore::open(root)?; + store.import_file(path, expected_sha256) +} + +/// Require a ready checksum, then load the in-process backend. +pub fn load( + root: &Path, + backend: &(impl InferenceBackend + ?Sized), + checksum: &str, +) -> Result<(), AppError> { + let store = ModelStore::open(root)?; + let _ready = store.get(checksum)?; + backend.load() +} + +/// Unload the in-process backend. Disk entries are unchanged. +pub fn unload(backend: &(impl InferenceBackend + ?Sized)) -> Result<(), AppError> { + backend.unload() +} + +/// Cooperative cancel of an in-flight `generate`. +pub fn cancel(backend: &(impl InferenceBackend + ?Sized)) { + backend.cancel(); +} + +/// UTF-8 prompt in, UTF-8 text out. No document path. +pub fn generate(backend: &(impl InferenceBackend + ?Sized), prompt: &str) -> Result { + backend.generate(prompt) +} + +/// Delete one checksum and report recovered blob bytes. +pub fn remove(root: &Path, checksum: &str) -> Result { + let store = ModelStore::open(root)?; + let recovered_bytes = recorded_remove_size(root, checksum); + store.remove(checksum)?; + Ok(ModelRemoveResult { + checksum: checksum.to_string(), + recovered_bytes, + }) +} + +/// Size from dest `manifests/.json`, else blob `metadata.len()`. +/// Does not hash. Truncated blobs still report the recorded size. +fn recorded_remove_size(root: &Path, checksum: &str) -> u64 { + let manifest_path = root.join("manifests").join(format!("{checksum}.json")); + if let Ok(json) = fs::read_to_string(&manifest_path) { + if let Ok(manifest) = ModelManifest::parse(&json) { + return manifest.size; + } + } + fs::metadata(root.join("blobs").join(checksum)) + .map(|meta| meta.len()) + .unwrap_or(0) +} + +/// Ready manifests on disk plus the current backend status. +pub fn snapshot( + root: &Path, + backend: &(impl InferenceBackend + ?Sized), +) -> Result { + let store = ModelStore::open(root)?; + Ok(ModelSetupSnapshot { + ready: store.list_ready()?, + backend_status: backend.status(), + }) +} + +fn sha256_file(path: &Path) -> Result<(u64, String), AppError> { + let mut file = File::open(path).map_err(|err| { + AppError::ai_model_invalid() + .with_details(format!("could not read {}: {err}", path.display())) + })?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + let mut size = 0u64; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + size += n as u64; + } + Ok((size, hex_lower(&hasher.finalize()))) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs index 5229c80..ac79d47 100644 --- a/src-tauri/src/ai/mod.rs +++ b/src-tauri/src/ai/mod.rs @@ -5,10 +5,12 @@ //! real backend is added later, keep the Fake injectable so tests still do //! not need weights. //! -//! No Tauri commands. Generate is UTF-8 prompt in, UTF-8 text out — not a -//! document path and not file bytes. +//! Generate is UTF-8 prompt in, UTF-8 text out — not a document path and not +//! file bytes. Settings IPC lives in `commands/ai.rs`, not here. mod fake; +pub(crate) mod lifecycle; +pub mod store; use crate::error::AppError; diff --git a/src-tauri/src/ai/store.rs b/src-tauri/src/ai/store.rs new file mode 100644 index 0000000..69b51eb --- /dev/null +++ b/src-tauri/src/ai/store.rs @@ -0,0 +1,398 @@ +//! Content-addressed on-disk model store. Injected root; no HTTP. +//! +//! Layout: `/{blobs,manifests,staging}/`. Identity is SHA-256. +//! `get` / `loadable_path` treat an entry as ready when the blob re-hashes to +//! the checksum. `list_ready` trusts recorded size (file exists, +//! `metadata.len() == size`) and does not SHA-256 every blob. Leftover +//! `staging/` is never ready. `open` sweeps leftover `staging/install-*` +//! except this process's `install-{pid}-*` (mid-import `open` must not +//! unlink live staging). + +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::AppError; + +const SCHEMA_VERSION: u32 = 1; +const CHECKSUM_LEN: usize = 64; +const LICENSE_PLACEHOLDER: &str = "imported"; +const RUNTIME_COMPAT_PLACEHOLDER: &str = "any"; + +/// Versioned model metadata. `schema_version` must be 1. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelManifest { + pub schema_version: u32, + pub size: u64, + pub license: String, + pub runtime_compat: String, + pub checksum: String, +} + +impl ModelManifest { + pub fn parse(json: &str) -> Result { + let parsed: Self = serde_json::from_str(json) + .map_err(|err| AppError::ai_model_invalid().with_details(err.to_string()))?; + if parsed.schema_version != SCHEMA_VERSION { + return Err(AppError::ai_model_invalid().with_details(format!( + "unsupported schema_version {}", + parsed.schema_version + ))); + } + validate_checksum(&parsed.checksum)?; + Ok(parsed) + } +} + +/// Persistent store under an injected root. Tests pass a temp path. +#[derive(Debug, Clone)] +pub struct ModelStore { + root: PathBuf, +} + +impl ModelStore { + pub fn open(root: &Path) -> Result { + let store = Self { + root: root.to_path_buf(), + }; + fs::create_dir_all(store.blobs_dir())?; + fs::create_dir_all(store.manifests_dir())?; + fs::create_dir_all(store.staging_dir())?; + store.sweep_install_staging(); + Ok(store) + } + + pub fn install_from_reader( + &self, + r: &mut impl Read, + expected_sha256: &str, + ) -> Result { + let expected = validate_checksum(expected_sha256)?; + let staging = self.new_staging_dir()?; + let _guard = StagingGuard(staging.clone()); + let staged_blob = staging.join("blob"); + let (size, actual) = write_hashed(r, &staged_blob)?; + if actual != expected { + return Err(AppError::ai_model_invalid().with_details(format!( + "checksum mismatch: expected {expected}, got {actual}" + ))); + } + + let manifest = ModelManifest { + schema_version: SCHEMA_VERSION, + size, + license: LICENSE_PLACEHOLDER.to_string(), + runtime_compat: RUNTIME_COMPAT_PLACEHOLDER.to_string(), + checksum: expected.clone(), + }; + self.commit_ready(&manifest, &staged_blob)?; + Ok(manifest) + } + + pub fn import_file( + &self, + path: &Path, + expected_sha256: &str, + ) -> Result { + let mut file = File::open(path).map_err(|err| { + AppError::ai_model_invalid() + .with_details(format!("could not read {}: {err}", path.display())) + })?; + if let Ok(meta) = file.metadata() { + ensure_disk_space(&self.staging_dir(), meta.len())?; + } + self.install_from_reader(&mut file, expected_sha256) + } + + pub fn list_ready(&self) -> Result, AppError> { + let mut ready = Vec::new(); + let dir = match fs::read_dir(self.manifests_dir()) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(ready); + } + Err(err) => return Err(err.into()), + }; + for entry in dir { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if validate_checksum(stem).is_err() { + continue; + } + if let Some(manifest) = self.listed_ready_manifest(stem) { + ready.push(manifest); + } + } + ready.sort_by(|a, b| a.checksum.cmp(&b.checksum)); + Ok(ready) + } + + pub fn get(&self, checksum: &str) -> Result { + let checksum = validate_checksum(checksum)?; + self.ready_manifest(&checksum)? + .ok_or_else(AppError::ai_model_not_found) + } + + pub fn loadable_path(&self, checksum: &str) -> Result { + let manifest = self.get(checksum)?; + Ok(self.blob_path(&manifest.checksum)) + } + + pub fn remove(&self, checksum: &str) -> Result<(), AppError> { + let checksum = validate_checksum(checksum)?; + let blob = self.blob_path(&checksum); + let manifest = self.manifest_path(&checksum); + let staging = self.staging_dir().join(&checksum); + let blob_exists = blob.exists(); + let manifest_exists = manifest.exists(); + let staging_exists = staging.exists(); + if !blob_exists && !manifest_exists && !staging_exists { + return Err(AppError::ai_model_not_found()); + } + if blob_exists { + fs::remove_file(&blob)?; + } + if manifest_exists { + fs::remove_file(&manifest)?; + } + if staging_exists { + fs::remove_dir_all(&staging)?; + } + Ok(()) + } + + fn blobs_dir(&self) -> PathBuf { + self.root.join("blobs") + } + + fn manifests_dir(&self) -> PathBuf { + self.root.join("manifests") + } + + fn staging_dir(&self) -> PathBuf { + self.root.join("staging") + } + + fn blob_path(&self, checksum: &str) -> PathBuf { + self.blobs_dir().join(checksum) + } + + fn manifest_path(&self, checksum: &str) -> PathBuf { + self.manifests_dir().join(format!("{checksum}.json")) + } + + fn new_staging_dir(&self) -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = self + .staging_dir() + .join(format!("install-{}-{nanos}", std::process::id())); + fs::create_dir_all(&dir)?; + Ok(dir) + } + + fn commit_ready(&self, manifest: &ModelManifest, staged_blob: &Path) -> Result<(), AppError> { + fs::create_dir_all(self.blobs_dir())?; + fs::create_dir_all(self.manifests_dir())?; + let dest_blob = self.blob_path(&manifest.checksum); + if !self.blob_matches(&dest_blob, manifest)? { + if dest_blob.exists() { + fs::remove_file(&dest_blob)?; + } + fs::rename(staged_blob, &dest_blob)?; + } + let dest_manifest = self.manifest_path(&manifest.checksum); + if dest_manifest.exists() { + if let Ok(existing) = fs::read_to_string(&dest_manifest) { + if let Ok(parsed) = ModelManifest::parse(&existing) { + if parsed == *manifest { + return Ok(()); + } + } + } + } + let json = serde_json::to_string(manifest) + .map_err(|err| AppError::ai_model_invalid().with_details(err.to_string()))?; + // Sibling temp + replace: Windows cannot rename over an existing dest. + let tmp_manifest = self + .manifests_dir() + .join(format!("{}.json.tmp", manifest.checksum)); + fs::write(&tmp_manifest, json.as_bytes())?; + if dest_manifest.exists() { + if let Err(err) = fs::remove_file(&dest_manifest) { + let _ = fs::remove_file(&tmp_manifest); + return Err(err.into()); + } + } + if let Err(err) = fs::rename(&tmp_manifest, &dest_manifest) { + let _ = fs::remove_file(&tmp_manifest); + return Err(err.into()); + } + Ok(()) + } + + fn ready_manifest(&self, checksum: &str) -> Result, AppError> { + let man_path = self.manifest_path(checksum); + if !man_path.is_file() { + return Ok(None); + } + let json = match fs::read_to_string(&man_path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + let manifest = match ModelManifest::parse(&json) { + Ok(parsed) => parsed, + Err(_) => return Ok(None), + }; + if manifest.checksum != checksum { + return Ok(None); + } + if !self.blob_matches(&self.blob_path(checksum), &manifest)? { + return Ok(None); + } + Ok(Some(manifest)) + } + + /// Size-only ready check for listing. Unreadable blobs are skipped. + fn listed_ready_manifest(&self, checksum: &str) -> Option { + let man_path = self.manifest_path(checksum); + if !man_path.is_file() { + return None; + } + let json = fs::read_to_string(&man_path).ok()?; + let manifest = ModelManifest::parse(&json).ok()?; + if manifest.checksum != checksum { + return None; + } + if !blob_size_matches(&self.blob_path(checksum), manifest.size) { + return None; + } + Some(manifest) + } + + fn sweep_install_staging(&self) { + let Ok(entries) = fs::read_dir(self.staging_dir()) else { + return; + }; + let live_prefix = format!("install-{}-", std::process::id()); + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if path.is_dir() && name.starts_with("install-") && !name.starts_with(&live_prefix) { + let _ = fs::remove_dir_all(&path); + } + } + } + + fn blob_matches(&self, path: &Path, manifest: &ModelManifest) -> Result { + if !path.is_file() { + return Ok(false); + } + let meta = fs::metadata(path)?; + if meta.len() != manifest.size { + return Ok(false); + } + let actual = hash_path(path)?; + Ok(actual == manifest.checksum) + } +} + +struct StagingGuard(PathBuf); + +impl Drop for StagingGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn validate_checksum(value: &str) -> Result { + let valid = value.len() == CHECKSUM_LEN + && value + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + if valid { + Ok(value.to_string()) + } else { + Err(AppError::ai_model_invalid() + .with_details("checksum must be 64 lowercase hex characters")) + } +} + +fn blob_size_matches(path: &Path, size: u64) -> bool { + match fs::metadata(path) { + Ok(meta) => meta.is_file() && meta.len() == size, + Err(_) => false, + } +} + +fn ensure_disk_space(dest: &Path, required: u64) -> Result<(), AppError> { + if required == 0 { + return Ok(()); + } + let info = crate::utils::disk::check(&dest.to_string_lossy(), required)?; + if !info.sufficient { + return Err(AppError::no_disk_space()); + } + Ok(()) +} + +fn write_hashed(src: &mut impl Read, dest: &Path) -> Result<(u64, String), AppError> { + let mut file = File::create(dest)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + let mut size = 0u64; + loop { + let n = src.read(&mut buf)?; + if n == 0 { + break; + } + file.write_all(&buf[..n])?; + hasher.update(&buf[..n]); + size += n as u64; + } + file.flush()?; + Ok((size, hex_lower(&hasher.finalize()))) +} + +fn hash_path(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex_lower(&hasher.finalize())) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} diff --git a/src-tauri/src/ai_lifecycle_tests.rs b/src-tauri/src/ai_lifecycle_tests.rs new file mode 100644 index 0000000..75d4eb1 --- /dev/null +++ b/src-tauri/src/ai_lifecycle_tests.rs @@ -0,0 +1,724 @@ +//! Unit tests for `crate::ai::lifecycle` (issue #84). +//! +//! Locked Approach A names: `preview`, `import`, `load`, `unload`, +//! `cancel`, `generate`, `remove`, `snapshot`. Injected store root + +//! `crate::ai::fake()`. Tiny fixture only — no GGUF. +//! Do not edit `ai_tests.rs`, `ai_store_tests.rs`, `store.rs`, or `fake.rs`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use crate::ai::lifecycle::{cancel, generate, import, load, preview, remove, snapshot, unload}; +use crate::ai::store::ModelStore; +use crate::ai::BackendStatus; + +const FIXTURE: &[u8] = b"offpdf-store-fixture-v1"; +const FIXTURE_SHA256: &str = "9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"; +const MISSING_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +const HARDWARE_STUB: &str = "Hardware check is not available in this version."; + +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn ai_src_dir() -> PathBuf { + manifest_dir().join("src/ai") +} + +fn walk_files(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + if path.is_dir() { + walk_files(&path, out); + } else { + out.push(path); + } + } +} + +fn file_count(dir: &Path) -> usize { + let mut n = 0usize; + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return 0, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + n += file_count(&path); + } else { + n += 1; + } + } + n +} + +fn is_under(child: &Path, root: &Path) -> bool { + let child = match child.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + let root = match root.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + child.starts_with(root) +} + +fn assert_ai_model_error(err: &crate::error::AppError, must_id: &str) { + assert!( + err.code.starts_with("AI_MODEL_"), + "{must_id}: AppError.code must be AI_MODEL_*, got {}", + err.code + ); + assert_ne!( + err.code, "ENGINE_FAILED", + "{must_id}: must not reuse the PDF-engine code" + ); + assert_ne!( + err.code, "AI_FAILED", + "{must_id}: must not reuse the Fake generate-fail code" + ); + assert!( + !err.title.is_empty(), + "{must_id}: AppError.title must be non-empty" + ); + assert!( + !err.message.is_empty(), + "{must_id}: AppError.message must be non-empty" + ); +} + +fn assert_fake_load_has_no_path(must_id: &str) { + let path = ai_src_dir().join("fake.rs"); + let src = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{must_id}: read {}: {err}", path.display())); + let mut rest = src.as_str(); + let mut saw = false; + let mut bad = Vec::new(); + while let Some(idx) = rest.find("fn load") { + let after = &rest[idx..]; + let end = after + .find('{') + .or_else(|| after.find(';')) + .unwrap_or(after.len()); + let sig = &after[..end]; + saw = true; + for token in ["PathBuf", "Path", "path:"] { + if sig.contains(token) { + bad.push(format!("{token} in `{sig}`")); + } + } + rest = &after["fn load".len()..]; + } + assert!( + saw, + "{must_id}: Fake load signature must still exist in src/ai/fake.rs" + ); + assert!( + bad.is_empty(), + "{must_id}: Fake load must stay pathless; found {bad:?}" + ); +} + +fn write_fixture(dir: &Path, name: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, FIXTURE).unwrap_or_else(|err| { + panic!( + "write fixture {} ({} bytes): {err}", + path.display(), + FIXTURE.len() + ) + }); + path +} + +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "offpdf-lifecycle-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// setup-facts-before-import — preview returns size / sha256 / license +/// `"imported"` / `runtime_compat` `"any"` / location / compat stub and +/// does not write a ready blob. +#[test] +fn lifecycle_preview_facts_before_import() { + let root = Scratch::new("preview"); + let src_dir = Scratch::new("preview-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let store = ModelStore::open(root.path()).unwrap_or_else(|err| { + panic!("setup-facts-before-import: open injected root: {err}") + }); + let blobs_before = file_count(&root.path().join("blobs")); + + let facts = preview(root.path(), &src).unwrap_or_else(|err| { + panic!("setup-facts-before-import: preview(path) must succeed: {err}") + }); + assert_eq!( + facts.size, + FIXTURE.len() as u64, + "setup-facts-before-import: size must be the source file length" + ); + assert_eq!( + facts.sha256, FIXTURE_SHA256, + "setup-facts-before-import: sha256 must be the SHA-256 of the source file" + ); + assert_eq!( + facts.license, "imported", + "setup-facts-before-import: license placeholder must be \"imported\"" + ); + assert_eq!( + facts.runtime_compat, "any", + "setup-facts-before-import: runtime_compat stub must be \"any\"" + ); + assert!( + facts.location.starts_with(root.path()) + || facts.location.to_string_lossy().contains("blobs"), + "setup-facts-before-import: location must name the injected store root or blobs/, got {}", + facts.location.display() + ); + assert!( + facts.compatibility.contains(HARDWARE_STUB), + "setup-facts-before-import: compatibility must include the #82 stub, got {:?}", + facts.compatibility + ); + + let ready = store.list_ready().unwrap_or_else(|err| { + panic!("setup-facts-before-import: list_ready after preview: {err}") + }); + assert!( + ready.is_empty(), + "setup-facts-before-import: preview must not import; list_ready was {}, not []", + ready.len() + ); + assert_eq!( + file_count(&root.path().join("blobs")), + blobs_before, + "setup-facts-before-import: preview must not create blobs" + ); + assert!( + src.exists(), + "setup-facts-before-import: preview must not move the source file" + ); +} + +/// setup-import-explicit-only — empty root lists []; import is a second +/// explicit call with path + expected sha; then one ready entry. +#[test] +fn lifecycle_import_explicit_only() { + let root = Scratch::new("import-explicit"); + let src_dir = Scratch::new("import-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let backend = crate::ai::fake(); + + let store = ModelStore::open(root.path()).unwrap_or_else(|err| { + panic!("setup-import-explicit-only: open empty root: {err}") + }); + let snap = snapshot(root.path(), &backend).unwrap_or_else(|err| { + panic!("setup-import-explicit-only: snapshot on empty root: {err}") + }); + assert!( + snap.ready.is_empty(), + "setup-import-explicit-only: empty root snapshot.ready must be [], got {} entries", + snap.ready.len() + ); + assert!( + store.list_ready().unwrap().is_empty(), + "setup-import-explicit-only: ModelStore list_ready on empty root must be []" + ); + + let facts = preview(root.path(), &src).unwrap_or_else(|err| { + panic!("setup-import-explicit-only: preview before import: {err}") + }); + assert!( + store.list_ready().unwrap().is_empty(), + "setup-import-explicit-only: preview still must not import" + ); + + let imported = import(root.path(), &src, &facts.sha256).unwrap_or_else(|err| { + panic!("setup-import-explicit-only: explicit import(path, expected sha): {err}") + }); + assert_eq!(imported.checksum, FIXTURE_SHA256); + assert_eq!(imported.license, "imported"); + assert_eq!(imported.runtime_compat, "any"); + assert!( + src.exists(), + "setup-import-explicit-only: import copies, it must not move the source" + ); + + let ready = store.list_ready().unwrap_or_else(|err| { + panic!("setup-import-explicit-only: list_ready after import: {err}") + }); + assert_eq!( + ready.len(), + 1, + "setup-import-explicit-only: explicit import must be the only ready entry" + ); + let loadable = store + .loadable_path(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-import-explicit-only: loadable_path: {err}")); + assert!( + is_under(&loadable, root.path()), + "setup-import-explicit-only: loadable_path {} must be under the injected root {}", + loadable.display(), + root.path().display() + ); +} + +/// setup-load-ready-then-fake — missing checksum is AI_MODEL_*; ready +/// checksum pathless-loads Fake to Ready. +#[test] +fn lifecycle_load_ready_then_fake() { + let root = Scratch::new("load"); + let src_dir = Scratch::new("load-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let backend = crate::ai::fake(); + + let err = load(root.path(), &backend, MISSING_SHA256).expect_err( + "setup-load-ready-then-fake: load of an unknown checksum must be AppError, not Ok", + ); + assert_ai_model_error(&err, "setup-load-ready-then-fake"); + assert!( + matches!(backend.status(), BackendStatus::Unloaded), + "setup-load-ready-then-fake: failed load must leave Fake Unloaded" + ); + + import(root.path(), &src, FIXTURE_SHA256).unwrap_or_else(|err| { + panic!("setup-load-ready-then-fake: import before load: {err}") + }); + load(root.path(), &backend, FIXTURE_SHA256).unwrap_or_else(|err| { + panic!("setup-load-ready-then-fake: load of a ready checksum: {err}") + }); + assert!( + matches!(backend.status(), BackendStatus::Ready), + "setup-load-ready-then-fake: Fake status after load must be Ready" + ); + assert_fake_load_has_no_path("setup-load-ready-then-fake"); + + unload(&backend).unwrap_or_else(|err| { + panic!("setup-load-ready-then-fake: unload must succeed: {err}") + }); + assert!( + matches!(backend.status(), BackendStatus::Unloaded), + "setup-load-ready-then-fake: Fake status after unload must be Unloaded" + ); +} + +/// setup-cancel-generate — in-flight generate("SLOW") + cancel → CANCELLED. +#[test] +fn lifecycle_cancel_generate() { + let root = Scratch::new("cancel"); + let src_dir = Scratch::new("cancel-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let backend = Arc::new(crate::ai::fake()); + + import(root.path(), &src, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-cancel-generate: import: {err}")); + load(root.path(), backend.as_ref(), FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-cancel-generate: load: {err}")); + + let worker = { + let backend = Arc::clone(&backend); + std::thread::spawn(move || generate(backend.as_ref(), "SLOW")) + }; + std::thread::sleep(Duration::from_millis(50)); + cancel(backend.as_ref()); + + let result = worker + .join() + .expect("setup-cancel-generate: generate thread must not panic"); + let err = result.expect_err("setup-cancel-generate: cancelled SLOW must be Err"); + assert_eq!( + err.code, + crate::error::AppError::cancelled().code, + "setup-cancel-generate: must reuse AppError::cancelled" + ); + assert_eq!( + err.code, "CANCELLED", + "setup-cancel-generate: .code must be CANCELLED" + ); + assert_ne!( + err.code, "ENGINE_FAILED", + "setup-cancel-generate: must not reuse ENGINE_FAILED" + ); +} + +/// setup-cancel-no-latch — import+load, do not call `cancel`; +/// `generate("hello")` is Ok. Settings dismiss of preview must not +/// call `lifecycle::cancel`. Keep SLOW+cancel → CANCELLED in +/// `lifecycle_cancel_generate`. +#[test] +fn lifecycle_cancel_no_latch() { + let root = Scratch::new("cancel-no-latch"); + let src_dir = Scratch::new("cancel-no-latch-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let backend = crate::ai::fake(); + + import(root.path(), &src, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-cancel-no-latch: import: {err}")); + load(root.path(), &backend, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-cancel-no-latch: load: {err}")); + + // Settings-style dismiss: drop preview only. Do not call cancel(). + let out = generate(&backend, "hello").unwrap_or_else(|err| { + panic!( + "setup-cancel-no-latch: generate(\"hello\") after dismiss-without-cancel must be Ok: {err}" + ) + }); + assert!( + !out.is_empty(), + "setup-cancel-no-latch: generate(\"hello\") must return UTF-8" + ); + assert_ne!( + out, "CANCELLED", + "setup-cancel-no-latch: generate must not look like a cancel error" + ); +} + +/// setup-remove-reports-bytes — remove returns recovered_bytes >= size; +/// second remove is AI_MODEL_*. +#[test] +fn lifecycle_remove_reports_bytes() { + let root = Scratch::new("remove"); + let src_dir = Scratch::new("remove-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let store = ModelStore::open(root.path()) + .unwrap_or_else(|err| panic!("setup-remove-reports-bytes: open: {err}")); + + import(root.path(), &src, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-remove-reports-bytes: import: {err}")); + let size = store + .get(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-remove-reports-bytes: get before remove: {err}")) + .size; + + let removed = remove(root.path(), FIXTURE_SHA256).unwrap_or_else(|err| { + panic!("setup-remove-reports-bytes: remove must succeed: {err}") + }); + assert_eq!( + removed.checksum, FIXTURE_SHA256, + "setup-remove-reports-bytes: result.checksum must name the removed model" + ); + assert!( + removed.recovered_bytes >= size, + "setup-remove-reports-bytes: recovered_bytes {} must be >= manifest.size {}", + removed.recovered_bytes, + size + ); + assert!( + store.list_ready().unwrap().is_empty(), + "setup-remove-reports-bytes: list_ready must be empty after remove" + ); + + let err = remove(root.path(), FIXTURE_SHA256) + .expect_err("setup-remove-reports-bytes: second remove must be AppError, not Ok"); + assert_ai_model_error(&err, "setup-remove-reports-bytes"); +} + +/// setup-restart-store-not-backend — reopen store keeps the ready entry; +/// a new Fake is Unloaded. +#[test] +fn lifecycle_restart_store_not_backend() { + let root = Scratch::new("restart"); + let src_dir = Scratch::new("restart-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let backend = crate::ai::fake(); + + import(root.path(), &src, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-restart-store-not-backend: import: {err}")); + load(root.path(), &backend, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("setup-restart-store-not-backend: load: {err}")); + assert!( + matches!(backend.status(), BackendStatus::Ready), + "setup-restart-store-not-backend: first process Fake must be Ready after load" + ); + + let store = ModelStore::open(root.path()).unwrap_or_else(|err| { + panic!("setup-restart-store-not-backend: reopen store: {err}") + }); + let fresh = crate::ai::fake(); + let snap = snapshot(root.path(), &fresh).unwrap_or_else(|err| { + panic!("setup-restart-store-not-backend: snapshot after reopen: {err}") + }); + assert_eq!( + snap.ready.len(), + 1, + "setup-restart-store-not-backend: reopened store must still list the ready entry" + ); + assert_eq!(snap.ready[0].checksum, FIXTURE_SHA256); + assert_eq!( + store.list_ready().unwrap().len(), + 1, + "setup-restart-store-not-backend: ModelStore::list_ready must still see the blob" + ); + assert!( + matches!(snap.backend_status, BackendStatus::Unloaded), + "setup-restart-store-not-backend: snapshot backend_status must be Unloaded for a new Fake" + ); + assert!( + matches!(fresh.status(), BackendStatus::Unloaded), + "setup-restart-store-not-backend: a new Fake must be Unloaded" + ); +} + +/// setup-zero-models-tools-ok — empty root lists []; Fake load/generate +/// still works with zero blobs. +#[test] +fn lifecycle_zero_models_ok() { + let root = Scratch::new("zero"); + let backend = crate::ai::fake(); + let snap = snapshot(root.path(), &backend).unwrap_or_else(|err| { + panic!("setup-zero-models-tools-ok: snapshot on empty root: {err}") + }); + assert!( + snap.ready.is_empty(), + "setup-zero-models-tools-ok: empty root must have no default model, got {} entries", + snap.ready.len() + ); + assert!( + !root.path().ends_with("jobs"), + "setup-zero-models-tools-ok: store root must not be /jobs" + ); + + backend + .load() + .expect("setup-zero-models-tools-ok: Fake load must succeed with zero store blobs"); + let out = backend + .generate("zero-models") + .expect("setup-zero-models-tools-ok: Fake generate must work with zero store blobs"); + assert!( + !out.is_empty(), + "setup-zero-models-tools-ok: Fake generate must return UTF-8 with zero models" + ); +} + +/// setup-no-http-no-gguf — new `ai/` files stay in the #81/#83 lock; +/// tests use the tiny in-memory fixture. +#[test] +fn lifecycle_no_http_no_gguf() { + assert_eq!( + FIXTURE, b"offpdf-store-fixture-v1", + "setup-no-http-no-gguf: tests must reuse the tiny in-memory FIXTURE" + ); + assert!( + FIXTURE.len() < 64, + "setup-no-http-no-gguf: FIXTURE must stay tiny, got {} bytes", + FIXTURE.len() + ); + + let ai_dir = ai_src_dir(); + assert!( + ai_dir.is_dir(), + "setup-no-http-no-gguf: src-tauri/src/ai/ must exist so the source lock can scan it" + ); + let lifecycle_rs = ai_dir.join("lifecycle.rs"); + let lifecycle_mod = ai_dir.join("lifecycle").join("mod.rs"); + assert!( + lifecycle_rs.is_file() || lifecycle_mod.is_file(), + "setup-no-http-no-gguf: src-tauri/src/ai/lifecycle.rs (or lifecycle/mod.rs) must exist" + ); + + let mut files = Vec::new(); + walk_files(&ai_dir, &mut files); + let rust_files: Vec = files + .into_iter() + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .collect(); + assert!( + !rust_files.is_empty(), + "setup-no-http-no-gguf: src-tauri/src/ai/ must contain Rust sources to scan" + ); + + const FORBIDDEN: &[&str] = &[ + "reqwest", + "ureq", + "std::net", + "std::process::Command", + "XAI_API_KEY", + "api.openai.com", + "api.x.ai", + "api.anthropic.com", + "generativelanguage.googleapis.com", + "api.groq.com", + ]; + let mut hits = Vec::new(); + for path in &rust_files { + let src = std::fs::read_to_string(path).unwrap_or_else(|err| { + panic!("setup-no-http-no-gguf: read {}: {err}", path.display()) + }); + for token in FORBIDDEN { + if src.contains(token) { + hits.push(format!("{}: {token}", path.display())); + } + } + } + assert!( + hits.is_empty(), + "setup-no-http-no-gguf: src-tauri/src/ai/** must not contain network, shell, or cloud-host tokens; found {hits:?}" + ); + + let crate_root = manifest_dir(); + let mut crate_files = Vec::new(); + walk_src_tauri_lock(&crate_root, &mut crate_files); + let mut gguf = Vec::new(); + let mut huge = Vec::new(); + for path in &crate_files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".gguf") || lower.ends_with(".ggml") || lower.ends_with(".safetensors") { + gguf.push(path.display().to_string()); + } + if let Ok(meta) = path.metadata() { + if meta.len() >= 1_000_000 { + huge.push(format!("{} ({} bytes)", path.display(), meta.len())); + } + } + } + assert!( + gguf.is_empty(), + "setup-no-http-no-gguf: src-tauri must not contain weight files; found {gguf:?}" + ); + assert!( + huge.is_empty(), + "setup-no-http-no-gguf: src-tauri/src and test fixtures must not add a multi-MB weight; found {huge:?}" + ); +} + +/// lifecycle-remove-size-without-hash — import fixture, note `size`. +/// Truncate dest blob to 0 bytes. `lifecycle::remove`. `recovered_bytes` +/// must equal the original recorded size (not 0). Fail-today: `get` +/// hashes the truncated blob and recovered collapses to 0. +#[test] +fn lifecycle_remove_size_without_hash() { + let root = Scratch::new("remove-size"); + let src_dir = Scratch::new("remove-size-src"); + let src = write_fixture(src_dir.path(), "model-bytes"); + let store = ModelStore::open(root.path()).unwrap_or_else(|err| { + panic!("lifecycle-remove-size-without-hash: open: {err}") + }); + + import(root.path(), &src, FIXTURE_SHA256).unwrap_or_else(|err| { + panic!("lifecycle-remove-size-without-hash: import: {err}") + }); + let size = store + .get(FIXTURE_SHA256) + .unwrap_or_else(|err| { + panic!("lifecycle-remove-size-without-hash: get before truncate: {err}") + }) + .size; + assert_eq!( + size, + FIXTURE.len() as u64, + "lifecycle-remove-size-without-hash: recorded size must be the fixture length" + ); + assert_ne!( + size, 0, + "lifecycle-remove-size-without-hash: original size must be non-zero so a 0 recovered_bytes is distinguishable" + ); + + let blobs = root.path().join("blobs"); + let mut blob_files = Vec::new(); + walk_files(&blobs, &mut blob_files); + assert!( + !blob_files.is_empty(), + "lifecycle-remove-size-without-hash: dest blob must exist after import" + ); + for path in &blob_files { + std::fs::write(path, b"").unwrap_or_else(|err| { + panic!( + "lifecycle-remove-size-without-hash: truncate {} to 0 bytes: {err}", + path.display() + ) + }); + let meta = std::fs::metadata(path).unwrap_or_else(|err| { + panic!( + "lifecycle-remove-size-without-hash: metadata after truncate {}: {err}", + path.display() + ) + }); + assert_eq!( + meta.len(), + 0, + "lifecycle-remove-size-without-hash: dest blob {} must be 0 bytes after truncate", + path.display() + ); + } + + let removed = remove(root.path(), FIXTURE_SHA256).unwrap_or_else(|err| { + panic!( + "lifecycle-remove-size-without-hash: remove of a truncated blob must succeed: {err}" + ) + }); + assert_eq!( + removed.checksum, FIXTURE_SHA256, + "lifecycle-remove-size-without-hash: result.checksum must name the removed model" + ); + assert_eq!( + removed.recovered_bytes, size, + "lifecycle-remove-size-without-hash: recovered_bytes must equal the original recorded size {size}, not the truncated blob length" + ); +} + +fn walk_src_tauri_lock(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if matches!( + name, + "binaries" + | "share" + | "tesseract" + | "libreoffice" + | "windows-runtime" + | "gen" + | "target" + | "vendor" + | "resources" + ) { + continue; + } + if path.is_dir() { + walk_src_tauri_lock(&path, out); + } else { + out.push(path); + } + } +} diff --git a/src-tauri/src/ai_store_tests.rs b/src-tauri/src/ai_store_tests.rs new file mode 100644 index 0000000..d76af21 --- /dev/null +++ b/src-tauri/src/ai_store_tests.rs @@ -0,0 +1,836 @@ +//! Unit tests for `crate::ai::store` (issue #83). +//! +//! Locked Approach A names: `ModelStore::open`, `import_file`, +//! `install_from_reader`, `list_ready`, `get`, `loadable_path`, `remove`, +//! and the versioned `ModelManifest` JSON (`schema_version`, size, license, +//! `runtime_compat`, SHA-256 hex `checksum`). +//! Do not edit `ai_tests.rs`. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::ai::store::{ModelManifest, ModelStore}; + +const FIXTURE: &[u8] = b"offpdf-store-fixture-v1"; +const FIXTURE_SHA256: &str = "9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"; +const WRONG_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn ai_src_dir() -> PathBuf { + manifest_dir().join("src/ai") +} + +fn walk_files(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + if path.is_dir() { + walk_files(&path, out); + } else { + out.push(path); + } + } +} + +fn tree_size(dir: &Path) -> u64 { + let mut total = 0u64; + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return 0, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + total += tree_size(&path); + } else if let Ok(meta) = entry.metadata() { + total += meta.len(); + } + } + total +} + +fn file_count(dir: &Path) -> usize { + let mut n = 0usize; + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return 0, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + n += file_count(&path); + } else { + n += 1; + } + } + n +} + +fn child_names(dir: &Path) -> HashSet { + let mut names = HashSet::new(); + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return names, + }; + for entry in entries.flatten() { + names.insert(entry.file_name().to_string_lossy().into_owned()); + } + names +} + +fn is_under(child: &Path, root: &Path) -> bool { + let child = match child.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + let root = match root.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + child.starts_with(root) +} + +fn assert_ai_model_error(err: &crate::error::AppError, must_id: &str) { + assert!( + err.code.starts_with("AI_MODEL_"), + "{must_id}: AppError.code must be AI_MODEL_*, got {}", + err.code + ); + assert_ne!( + err.code, "ENGINE_FAILED", + "{must_id}: must not reuse the PDF-engine code" + ); + assert_ne!( + err.code, "AI_FAILED", + "{must_id}: must not reuse the Fake generate-fail code" + ); + assert!( + !err.title.is_empty(), + "{must_id}: AppError.title must be non-empty" + ); + assert!( + !err.message.is_empty(), + "{must_id}: AppError.message must be non-empty" + ); +} + +fn valid_manifest_json() -> String { + format!( + r#"{{"schema_version":1,"size":{size},"license":"CC0-1.0","runtime_compat":"test","checksum":"{sum}"}}"#, + size = FIXTURE.len(), + sum = FIXTURE_SHA256 + ) +} + +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "offpdf-store-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// store-manifest-parse — valid fixture JSON parses; truncated / missing +/// fields / bad schema_version rejected. +#[test] +fn store_manifest_parse() { + let parsed = ModelManifest::parse(&valid_manifest_json()) + .unwrap_or_else(|err| panic!("store-manifest-parse: valid fixture JSON must parse: {err}")); + assert_eq!( + parsed.schema_version, 1, + "store-manifest-parse: schema_version must be 1" + ); + assert_eq!( + parsed.size, + FIXTURE.len() as u64, + "store-manifest-parse: size must match the fixture byte length" + ); + assert_eq!( + parsed.license, "CC0-1.0", + "store-manifest-parse: license must round-trip" + ); + assert_eq!( + parsed.runtime_compat, "test", + "store-manifest-parse: runtime_compat is an opaque string" + ); + assert_eq!( + parsed.checksum, FIXTURE_SHA256, + "store-manifest-parse: checksum must be the SHA-256 hex" + ); + + let truncated = r#"{"schema_version":1,"size":23,"license":"CC0-1.0""#; + let err = ModelManifest::parse(truncated) + .expect_err("store-manifest-parse: truncated JSON must be rejected"); + assert_ai_model_error(&err, "store-manifest-parse"); + + let not_json = "not-json"; + let err = ModelManifest::parse(not_json) + .expect_err("store-manifest-parse: non-JSON must be rejected"); + assert_ai_model_error(&err, "store-manifest-parse"); + + let missing = [ + r#"{"size":23,"license":"CC0-1.0","runtime_compat":"test","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"license":"CC0-1.0","runtime_compat":"test","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"size":23,"runtime_compat":"test","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"size":23,"license":"CC0-1.0","checksum":"9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"}"#, + r#"{"schema_version":1,"size":23,"license":"CC0-1.0","runtime_compat":"test"}"#, + ]; + for json in missing { + let err = ModelManifest::parse(json) + .expect_err("store-manifest-parse: JSON missing a required field must be rejected"); + assert_ai_model_error(&err, "store-manifest-parse"); + } + + for version in [0_u32, 999] { + let json = format!( + r#"{{"schema_version":{version},"size":23,"license":"CC0-1.0","runtime_compat":"test","checksum":"{FIXTURE_SHA256}"}}"# + ); + let err = ModelManifest::parse(&json).expect_err(&format!( + "store-manifest-parse: schema_version {version} must be rejected" + )); + assert_ai_model_error(&err, "store-manifest-parse"); + } +} + +/// store-import-ready — import FIXTURE + matching sha256 → only ready entry; +/// loadable_path under the injected root. +#[test] +fn store_import_ready() { + let root = Scratch::new("import-ready"); + let store = ModelStore::open(root.path()) + .unwrap_or_else(|err| panic!("store-import-ready: open injected root must succeed: {err}")); + + let mut reader = std::io::Cursor::new(FIXTURE); + let installed = store + .install_from_reader(&mut reader, FIXTURE_SHA256) + .unwrap_or_else(|err| { + panic!("store-import-ready: install_from_reader + matching sha256: {err}") + }); + assert_eq!( + installed.checksum, FIXTURE_SHA256, + "store-import-ready: checksum is the id" + ); + assert_eq!( + installed.size, + FIXTURE.len() as u64, + "store-import-ready: size must be the fixture length" + ); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-import-ready: list_ready: {err}")); + assert_eq!( + ready.len(), + 1, + "store-import-ready: matching import must be the only ready entry" + ); + assert_eq!(ready[0].checksum, FIXTURE_SHA256); + + let got = store + .get(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: get(checksum) after import: {err}")); + assert_eq!(got.checksum, FIXTURE_SHA256); + assert_eq!(got.size, FIXTURE.len() as u64); + + let path = store + .loadable_path(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: loadable_path: {err}")); + assert!( + is_under(&path, root.path()), + "store-import-ready: loadable_path {} must be under the injected root {}", + path.display(), + root.path().display() + ); + let bytes = std::fs::read(&path) + .unwrap_or_else(|err| panic!("store-import-ready: loadable blob must exist: {err}")); + assert_eq!( + bytes, FIXTURE, + "store-import-ready: loadable blob must be the fixture bytes" + ); + + let src_dir = Scratch::new("import-src"); + let src = src_dir.path().join("model-bytes"); + std::fs::write(&src, FIXTURE).unwrap(); + let names_before = child_names(src_dir.path()); + let imported = store + .import_file(&src, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: import_file + matching sha256: {err}")); + assert_eq!(imported.checksum, FIXTURE_SHA256); + assert!( + src.exists(), + "store-import-ready: import_file copies, it must not move the source" + ); + assert_eq!( + child_names(src_dir.path()), + names_before, + "store-import-ready: import must not write next to the source file" + ); + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-import-ready: list_ready after file import: {err}")); + assert_eq!( + ready.len(), + 1, + "store-import-ready: same bytes via import_file stay one ready entry" + ); + let loadable = store + .loadable_path(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-import-ready: loadable_path after file import: {err}")); + assert!( + is_under(&loadable, root.path()), + "store-import-ready: file-import loadable_path must stay under the store root" + ); + assert_ne!( + loadable.canonicalize().ok(), + src.canonicalize().ok(), + "store-import-ready: loadable_path must not be the caller's source file" + ); +} + +/// store-checksum-mismatch-not-ready — wrong digest → AppError (AI_MODEL_*, +/// not ENGINE_FAILED), not in list_ready. +#[test] +fn store_checksum_mismatch_not_ready() { + let root = Scratch::new("checksum-mismatch"); + let store = ModelStore::open(root.path()) + .unwrap_or_else(|err| panic!("store-checksum-mismatch-not-ready: open: {err}")); + + let mut reader = std::io::Cursor::new(FIXTURE); + let err = store + .install_from_reader(&mut reader, WRONG_SHA256) + .expect_err("store-checksum-mismatch-not-ready: wrong digest must return AppError, not Ok"); + assert_ai_model_error(&err, "store-checksum-mismatch-not-ready"); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-checksum-mismatch-not-ready: list_ready: {err}")); + assert!( + ready.is_empty(), + "store-checksum-mismatch-not-ready: mismatched install must not appear in list_ready, got {} entries", + ready.len() + ); + assert!( + store.get(WRONG_SHA256).is_err(), + "store-checksum-mismatch-not-ready: wrong digest must not be get-able" + ); + assert!( + store.get(FIXTURE_SHA256).is_err(), + "store-checksum-mismatch-not-ready: fixture checksum must not be ready after a mismatch" + ); +} + +/// store-partial-not-ready — leftover staging/ or a truncated blob → not in +/// list_ready. +#[test] +fn store_partial_not_ready() { + let leftover = Scratch::new("partial-staging"); + std::fs::create_dir_all(leftover.path().join("staging").join("interrupted")).unwrap(); + std::fs::write( + leftover + .path() + .join("staging") + .join("interrupted") + .join("blob"), + FIXTURE, + ) + .unwrap(); + let store = ModelStore::open(leftover.path()) + .unwrap_or_else(|err| panic!("store-partial-not-ready: open leftover staging root: {err}")); + let ready = store.list_ready().unwrap_or_else(|err| { + panic!("store-partial-not-ready: list_ready on leftover staging: {err}") + }); + assert!( + ready.is_empty(), + "store-partial-not-ready: leftover staging/ must not appear in list_ready, got {} entries", + ready.len() + ); + + let truncated = Scratch::new("partial-trunc"); + let store = ModelStore::open(truncated.path()) + .unwrap_or_else(|err| panic!("store-partial-not-ready: open truncate root: {err}")); + let mut reader = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut reader, FIXTURE_SHA256) + .unwrap_or_else(|err| { + panic!("store-partial-not-ready: matching install before truncate: {err}") + }); + let blobs = truncated.path().join("blobs"); + assert!( + blobs.is_dir(), + "store-partial-not-ready: committed blob must live under /blobs" + ); + let mut blob_files = Vec::new(); + walk_files(&blobs, &mut blob_files); + assert!( + !blob_files.is_empty(), + "store-partial-not-ready: blobs/ must contain the committed fixture" + ); + for path in &blob_files { + std::fs::write(path, &FIXTURE[..5]).unwrap_or_else(|err| { + panic!( + "store-partial-not-ready: truncate {}: {err}", + path.display() + ) + }); + } + let store = ModelStore::open(truncated.path()) + .unwrap_or_else(|err| panic!("store-partial-not-ready: reopen after truncate: {err}")); + let ready = store.list_ready().unwrap_or_else(|err| { + panic!("store-partial-not-ready: list_ready after truncated blob: {err}") + }); + assert!( + ready.is_empty(), + "store-partial-not-ready: truncated blob must not appear in list_ready, got {} entries", + ready.len() + ); +} + +/// store-dedup-no-waste — import the same bytes twice → one blob; blob-tree +/// size does not double. +#[test] +fn store_dedup_no_waste() { + let root = Scratch::new("dedup"); + let store = ModelStore::open(root.path()) + .unwrap_or_else(|err| panic!("store-dedup-no-waste: open: {err}")); + + let mut first = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut first, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-dedup-no-waste: first install: {err}")); + let blobs = root.path().join("blobs"); + let size_after_first = tree_size(&blobs); + let files_after_first = file_count(&blobs); + assert!( + size_after_first >= FIXTURE.len() as u64, + "store-dedup-no-waste: blobs/ must hold at least the fixture after the first import" + ); + assert!( + files_after_first >= 1, + "store-dedup-no-waste: first import must create a blob file" + ); + + let mut second = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut second, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-dedup-no-waste: second install: {err}")); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-dedup-no-waste: list_ready: {err}")); + assert_eq!( + ready.len(), + 1, + "store-dedup-no-waste: same checksum is one ready model, got {} entries", + ready.len() + ); + + let size_after_second = tree_size(&blobs); + let files_after_second = file_count(&blobs); + assert_eq!( + files_after_second, files_after_first, + "store-dedup-no-waste: second import must not add another blob file" + ); + assert_eq!( + size_after_second, size_after_first, + "store-dedup-no-waste: blob-tree size must not grow on a duplicate import" + ); + assert!( + size_after_second < (FIXTURE.len() as u64).saturating_mul(2), + "store-dedup-no-waste: blob-tree size must not reach two copies of the fixture" + ); +} + +/// store-remove-all — remove deletes that model's manifest, blob, staging; +/// list_ready empty. Second remove is a structured not-found. +#[test] +fn store_remove_all() { + let root = Scratch::new("remove"); + let store = + ModelStore::open(root.path()).unwrap_or_else(|err| panic!("store-remove-all: open: {err}")); + + let mut reader = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut reader, FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-remove-all: install: {err}")); + + std::fs::create_dir_all(root.path().join("staging").join(FIXTURE_SHA256)).unwrap(); + std::fs::write( + root.path() + .join("staging") + .join(FIXTURE_SHA256) + .join("leftover"), + FIXTURE, + ) + .unwrap(); + + store + .remove(FIXTURE_SHA256) + .unwrap_or_else(|err| panic!("store-remove-all: first remove: {err}")); + + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-remove-all: list_ready after remove: {err}")); + assert!( + ready.is_empty(), + "store-remove-all: list_ready must be empty after remove, got {} entries", + ready.len() + ); + assert_eq!( + tree_size(&root.path().join("blobs")), + 0, + "store-remove-all: blob for this checksum must be gone" + ); + assert_eq!( + tree_size(&root.path().join("manifests")), + 0, + "store-remove-all: manifest for this checksum must be gone" + ); + assert_eq!( + tree_size(&root.path().join("staging")), + 0, + "store-remove-all: leftover staging for this model must be gone" + ); + + let err = store + .remove(FIXTURE_SHA256) + .expect_err("store-remove-all: second remove must be a structured not-found, not Ok"); + assert_ai_model_error(&err, "store-remove-all"); +} + +/// store-no-auto-download — open empty root + list_ready: no URL, no default +/// model, no writes outside the injected root. +#[test] +fn store_no_auto_download() { + let parent = Scratch::new("no-auto-parent"); + let root = parent.path().join("store-root"); + std::fs::create_dir_all(&root).unwrap(); + let parent_before = child_names(parent.path()); + + let store = ModelStore::open(&root).unwrap_or_else(|err| { + panic!("store-no-auto-download: open empty root must not need a URL: {err}") + }); + let ready = store + .list_ready() + .unwrap_or_else(|err| panic!("store-no-auto-download: list_ready on empty root: {err}")); + assert!( + ready.is_empty(), + "store-no-auto-download: empty root must have no default model, got {} entries", + ready.len() + ); + + assert_eq!( + child_names(parent.path()), + parent_before, + "store-no-auto-download: open + list_ready must not write outside the injected root" + ); + assert!( + !root.ends_with("jobs"), + "store-no-auto-download: injected root must not be /jobs" + ); +} + +/// store-no-network-no-shell — `src-tauri/src/ai/**` still has no +/// reqwest/ureq/std::net/Command/XAI_API_KEY/cloud host. store.rs must exist. +#[test] +fn store_no_network_no_shell() { + let ai_dir = ai_src_dir(); + assert!( + ai_dir.is_dir(), + "store-no-network-no-shell: src-tauri/src/ai/ must exist so the source lock can scan it" + ); + + let store_rs = ai_dir.join("store.rs"); + let store_mod = ai_dir.join("store").join("mod.rs"); + assert!( + store_rs.is_file() || store_mod.is_file(), + "store-no-network-no-shell: src-tauri/src/ai/store.rs (or store/mod.rs) must exist" + ); + + let mut files = Vec::new(); + walk_files(&ai_dir, &mut files); + let rust_files: Vec = files + .into_iter() + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .collect(); + assert!( + !rust_files.is_empty(), + "store-no-network-no-shell: src-tauri/src/ai/ must contain Rust sources to scan" + ); + + const FORBIDDEN: &[&str] = &[ + "reqwest", + "ureq", + "std::net", + "std::process::Command", + "XAI_API_KEY", + "api.openai.com", + "api.x.ai", + "api.anthropic.com", + "generativelanguage.googleapis.com", + "api.groq.com", + ]; + + let mut hits = Vec::new(); + for path in &rust_files { + let src = std::fs::read_to_string(path).unwrap_or_else(|err| { + panic!("store-no-network-no-shell: read {}: {err}", path.display()) + }); + for token in FORBIDDEN { + if src.contains(token) { + hits.push(format!("{}: {token}", path.display())); + } + } + } + assert!( + hits.is_empty(), + "store-no-network-no-shell: src-tauri/src/ai/** must not contain network, shell, or cloud-host tokens; found {hits:?}" + ); +} + +/// store-no-gguf-in-clone — walk crate sources (skip gitignored bundle dirs): +/// no `.gguf` / `.ggml` / `.safetensors` and no huge weight. Tests use +/// in-memory FIXTURE. +#[test] +fn store_no_gguf_in_clone() { + assert_eq!( + FIXTURE, b"offpdf-store-fixture-v1", + "store-no-gguf-in-clone: tests must use the tiny in-memory FIXTURE" + ); + assert!( + FIXTURE.len() < 64, + "store-no-gguf-in-clone: FIXTURE must stay tiny, got {} bytes", + FIXTURE.len() + ); + + let crate_root = manifest_dir(); + let mut files = Vec::new(); + walk_src_tauri_lock(&crate_root, &mut files); + + let mut gguf = Vec::new(); + let mut huge = Vec::new(); + for path in &files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.to_ascii_lowercase().ends_with(".gguf") + || name.to_ascii_lowercase().ends_with(".ggml") + || name.to_ascii_lowercase().ends_with(".safetensors") + { + gguf.push(path.display().to_string()); + } + if let Ok(meta) = path.metadata() { + if meta.len() >= 1_000_000 { + huge.push(format!("{} ({} bytes)", path.display(), meta.len())); + } + } + } + assert!( + gguf.is_empty(), + "store-no-gguf-in-clone: src-tauri must not contain weight files; found {gguf:?}" + ); + assert!( + huge.is_empty(), + "store-no-gguf-in-clone: src-tauri/src and test fixtures must not add a multi-MB weight; found {huge:?}" + ); +} + +/// store-sweep-install-staging — leftover `/staging/install-dead/` +/// (junk blob) is gone after `ModelStore::open`. Crash leftovers must +/// not accumulate; do not rely on Drop. +#[test] +fn store_sweep_install_staging() { + let root = Scratch::new("sweep-install"); + let dead = root.path().join("staging").join("install-dead"); + std::fs::create_dir_all(&dead).unwrap(); + std::fs::write(dead.join("blob"), b"junk").unwrap(); + assert!( + dead.is_dir(), + "store-sweep-install-staging: pre-condition: leftover staging/install-dead must exist before open" + ); + + let _store = ModelStore::open(root.path()).unwrap_or_else(|err| { + panic!("store-sweep-install-staging: open injected root must succeed: {err}") + }); + + assert!( + !dead.exists(), + "store-sweep-install-staging: leftover staging/install-dead must be gone after ModelStore::open" + ); +} + +/// store-commit-over-existing-manifest — import fixture, overwrite dest +/// `manifests/.json` with valid JSON but a different `license` +/// (same checksum/size/schema), import again. `list_ready` has one +/// entry; `get` works. Locks dest replace when the dest `*.json` +/// already exists (Unix rename-over still passes on macOS). +#[test] +fn store_commit_over_existing_manifest() { + let root = Scratch::new("commit-over-manifest"); + let store = ModelStore::open(root.path()).unwrap_or_else(|err| { + panic!("store-commit-over-existing-manifest: open: {err}") + }); + + let mut first = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut first, FIXTURE_SHA256) + .unwrap_or_else(|err| { + panic!("store-commit-over-existing-manifest: first import: {err}") + }); + + let dest = root + .path() + .join("manifests") + .join(format!("{FIXTURE_SHA256}.json")); + assert!( + dest.is_file(), + "store-commit-over-existing-manifest: first import must write dest manifests/.json" + ); + + const OTHER_LICENSE: &str = "OTHER-LICENSE"; + let overwritten = format!( + r#"{{"schema_version":1,"size":{size},"license":"{license}","runtime_compat":"any","checksum":"{sum}"}}"#, + size = FIXTURE.len(), + license = OTHER_LICENSE, + sum = FIXTURE_SHA256 + ); + let parsed = ModelManifest::parse(&overwritten).unwrap_or_else(|err| { + panic!( + "store-commit-over-existing-manifest: overwritten dest JSON must be valid: {err}" + ) + }); + assert_eq!(parsed.schema_version, 1); + assert_eq!(parsed.size, FIXTURE.len() as u64); + assert_eq!(parsed.checksum, FIXTURE_SHA256); + assert_eq!(parsed.license, OTHER_LICENSE); + std::fs::write(&dest, overwritten.as_bytes()).unwrap(); + + let mut second = std::io::Cursor::new(FIXTURE); + store + .install_from_reader(&mut second, FIXTURE_SHA256) + .unwrap_or_else(|err| { + panic!("store-commit-over-existing-manifest: re-import over existing dest json: {err}") + }); + + let ready = store.list_ready().unwrap_or_else(|err| { + panic!("store-commit-over-existing-manifest: list_ready after re-import: {err}") + }); + assert_eq!( + ready.len(), + 1, + "store-commit-over-existing-manifest: re-import must leave one ready entry, got {}", + ready.len() + ); + assert_eq!(ready[0].checksum, FIXTURE_SHA256); + + let got = store.get(FIXTURE_SHA256).unwrap_or_else(|err| { + panic!("store-commit-over-existing-manifest: get after re-import: {err}") + }); + assert_eq!(got.checksum, FIXTURE_SHA256); + assert_eq!(got.size, FIXTURE.len() as u64); + assert_eq!( + got.license, "imported", + "store-commit-over-existing-manifest: dest json must be replaced, not left as {OTHER_LICENSE}" + ); +} + +/// store-sweep-skip-live-pid — leftover `staging/install-{pid}-live/` +/// survives `ModelStore::open`; crash leftover `install-dead` is still +/// removed. Mid-import `open` (list/snapshot) must not unlink this +/// process's staging dir. Keep `store_sweep_install_staging`. +#[test] +fn store_sweep_skip_live_pid() { + let root = Scratch::new("sweep-skip-live"); + let live_name = format!("install-{}-live", std::process::id()); + let live = root.path().join("staging").join(&live_name); + let dead = root.path().join("staging").join("install-dead"); + std::fs::create_dir_all(&live).unwrap(); + std::fs::write(live.join("blob"), b"junk").unwrap(); + std::fs::create_dir_all(&dead).unwrap(); + std::fs::write(dead.join("blob"), b"junk").unwrap(); + assert!( + live.is_dir(), + "store-sweep-skip-live-pid: pre-condition: staging/{live_name} must exist before open" + ); + assert!( + dead.is_dir(), + "store-sweep-skip-live-pid: pre-condition: leftover staging/install-dead must exist before open" + ); + + let _store = ModelStore::open(root.path()).unwrap_or_else(|err| { + panic!("store-sweep-skip-live-pid: open injected root must succeed: {err}") + }); + + assert!( + live.exists(), + "store-sweep-skip-live-pid: staging/{live_name} must still exist after ModelStore::open (this process's live staging)" + ); + assert!( + live.join("blob").is_file(), + "store-sweep-skip-live-pid: live staging blob must not be swept" + ); + assert!( + !dead.exists(), + "store-sweep-skip-live-pid: leftover staging/install-dead must be gone after ModelStore::open" + ); +} + +fn walk_src_tauri_lock(dir: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(_) => continue, + }; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Local prepare-* trees drop multi-MB tools here; they are not weights. + if matches!( + name, + "binaries" + | "share" + | "tesseract" + | "libreoffice" + | "windows-runtime" + | "gen" + | "target" + | "vendor" + | "resources" + ) { + continue; + } + if path.is_dir() { + walk_src_tauri_lock(&path, out); + } else { + out.push(path); + } + } +} diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs new file mode 100644 index 0000000..69ffa35 --- /dev/null +++ b/src-tauri/src/commands/ai.rs @@ -0,0 +1,284 @@ +//! Local-model setup IPC. Paths in, never file bytes. No HTTP. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; + +use crate::ai::lifecycle::{ + cancel, generate, import, load, preview, remove, snapshot, unload, ModelPreview, + ModelRemoveResult, +}; +use crate::ai::store::ModelManifest; +use crate::ai::{BackendStatus, InferenceBackend}; +use crate::error::AppError; + +/// In-process Fake plus the checksum last passed to `load`. Not persisted. +pub struct AiSession { + backend: Box, + selected: Mutex>, +} + +impl AiSession { + pub fn new() -> Self { + Self { + backend: Box::new(crate::ai::fake()), + selected: Mutex::new(None), + } + } + + fn backend(&self) -> &dyn InferenceBackend { + self.backend.as_ref() + } + + fn selected(&self) -> Option { + self.selected + .lock() + .unwrap_or_else(|err| err.into_inner()) + .clone() + } + + fn set_selected(&self, checksum: Option) { + *self.selected.lock().unwrap_or_else(|err| err.into_inner()) = checksum; + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelPreviewDto { + pub size: u64, + pub sha256: String, + pub license: String, + pub runtime_compat: String, + pub location: String, + pub compatibility: String, +} + +impl From for ModelPreviewDto { + fn from(facts: ModelPreview) -> Self { + Self { + size: facts.size, + sha256: facts.sha256, + license: facts.license, + runtime_compat: facts.runtime_compat, + location: facts.location.to_string_lossy().into_owned(), + compatibility: facts.compatibility, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelManifestDto { + pub schema_version: u32, + pub size: u64, + pub license: String, + pub runtime_compat: String, + pub checksum: String, +} + +impl From for ModelManifestDto { + fn from(manifest: ModelManifest) -> Self { + Self { + schema_version: manifest.schema_version, + size: manifest.size, + license: manifest.license, + runtime_compat: manifest.runtime_compat, + checksum: manifest.checksum, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelHealthDto { + pub ok: bool, + pub backend_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelSetupSnapshotDto { + pub ready: Vec, + pub backend_status: String, + pub health: ModelHealthDto, + pub selected_checksum: Option, + pub store_root: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelRemoveResultDto { + pub checksum: String, + pub recovered_bytes: u64, +} + +impl From for ModelRemoveResultDto { + fn from(removed: ModelRemoveResult) -> Self { + Self { + checksum: removed.checksum, + recovered_bytes: removed.recovered_bytes, + } + } +} + +fn store_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map_err(|err| AppError::io("Could not resolve the app data directory.", err)) +} + +fn join_err(err: impl std::fmt::Display) -> AppError { + AppError::io("The local-model command was interrupted.", err) +} + +fn filepath_to_string(fp: tauri_plugin_dialog::FilePath) -> Option { + fp.into_path().ok().map(|p| p.to_string_lossy().to_string()) +} + +fn status_label(status: BackendStatus) -> String { + match status { + BackendStatus::Unloaded => "Unloaded".to_string(), + BackendStatus::Ready => "Ready".to_string(), + } +} + +/// Native picker with no PDF / GGUF filter. `null` if the user cancelled. +#[tauri::command] +pub async fn ai_pick_model_file(app: tauri::AppHandle) -> Result, AppError> { + let picked = tauri::async_runtime::spawn_blocking(move || { + // No add_filter: macOS treats ["*"] as a literal extension, so .txt stays grey. + app.dialog().file().blocking_pick_file() + }) + .await + .map_err(join_err)?; + + Ok(picked.and_then(filepath_to_string)) +} + +/// Hash a chosen path. Does not write a blob. +#[tauri::command] +pub async fn ai_preview_model(app: tauri::AppHandle, path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = store_root(&app)?; + preview(&root, Path::new(&path)).map(ModelPreviewDto::from) + }) + .await + .map_err(join_err)? +} + +/// Copy the chosen file into the store after the user confirms the preview hash. +#[tauri::command] +pub async fn ai_import_model( + app: tauri::AppHandle, + path: String, + expected_sha256: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = store_root(&app)?; + import(&root, Path::new(&path), &expected_sha256).map(ModelManifestDto::from) + }) + .await + .map_err(join_err)? +} + +/// Ready manifests on disk plus the current Fake status. Does not auto-load. +#[tauri::command] +pub async fn ai_list_models( + app: tauri::AppHandle, + session: tauri::State<'_, Arc>, +) -> Result { + let session = Arc::clone(&session); + tauri::async_runtime::spawn_blocking(move || { + let root = store_root(&app)?; + let snap = snapshot(&root, session.backend())?; + let health = session.backend().health(); + Ok(ModelSetupSnapshotDto { + ready: snap.ready.into_iter().map(ModelManifestDto::from).collect(), + backend_status: status_label(snap.backend_status), + health: ModelHealthDto { + ok: health.ok, + backend_id: health.backend_id.to_string(), + }, + selected_checksum: session.selected(), + store_root: root.to_string_lossy().into_owned(), + }) + }) + .await + .map_err(join_err)? +} + +/// Load a ready checksum into the Fake. Checksum is remembered in process memory. +#[tauri::command] +pub async fn ai_load_model( + app: tauri::AppHandle, + session: tauri::State<'_, Arc>, + checksum: String, +) -> Result<(), AppError> { + let session = Arc::clone(&session); + tauri::async_runtime::spawn_blocking(move || { + let root = store_root(&app)?; + load(&root, session.backend(), &checksum)?; + session.set_selected(Some(checksum)); + Ok(()) + }) + .await + .map_err(join_err)? +} + +/// Unload the Fake and clear the selected checksum. +#[tauri::command] +pub async fn ai_unload_model( + session: tauri::State<'_, Arc>, +) -> Result<(), AppError> { + let session = Arc::clone(&session); + tauri::async_runtime::spawn_blocking(move || { + unload(session.backend())?; + session.set_selected(None); + Ok(()) + }) + .await + .map_err(join_err)? +} + +/// Cooperative cancel of an in-flight generate. +#[tauri::command] +pub fn ai_cancel(session: tauri::State<'_, Arc>) -> Result<(), AppError> { + cancel(session.backend()); + Ok(()) +} + +/// Generate from a prompt. No PDF path. +#[tauri::command] +pub async fn ai_generate( + session: tauri::State<'_, Arc>, + prompt: String, +) -> Result { + let session = Arc::clone(&session); + tauri::async_runtime::spawn_blocking(move || generate(session.backend(), &prompt)) + .await + .map_err(join_err)? +} + +/// Delete one imported model and report recovered bytes. +#[tauri::command] +pub async fn ai_remove_model( + app: tauri::AppHandle, + session: tauri::State<'_, Arc>, + checksum: String, +) -> Result { + let session = Arc::clone(&session); + tauri::async_runtime::spawn_blocking(move || { + let root = store_root(&app)?; + let removed = remove(&root, &checksum)?; + if session.selected().as_deref() == Some(checksum.as_str()) { + let _ = unload(session.backend()); + session.set_selected(None); + } + Ok(ModelRemoveResultDto::from(removed)) + }) + .await + .map_err(join_err)? +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 95e59c6..f84e522 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ //! Tauri command modules. Each `#[tauri::command]` here is registered in //! `lib.rs`. Commands only ever pass file *paths* across IPC — never bytes. +pub mod ai; pub mod files; pub mod pdf; pub mod jobs; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 7913d6f..17de9ec 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -74,7 +74,9 @@ impl AppError { "Not enough disk space", "There may not be enough free space to complete this operation safely.", ) - .with_suggestion("Free up disk space, or choose an output folder on a drive with more room.") + .with_suggestion( + "Free up disk space, or choose an output folder on a drive with more room.", + ) } pub fn engine_failed(details: impl Into) -> Self { @@ -93,7 +95,9 @@ impl AppError { "PDF engine not found", "The bundled qpdf engine could not be located and qpdf is not on your PATH.", ) - .with_suggestion("Reinstall OffPDF, or install qpdf so it is available on your system PATH.") + .with_suggestion( + "Reinstall OffPDF, or install qpdf so it is available on your system PATH.", + ) } pub fn cancelled() -> Self { @@ -120,6 +124,23 @@ impl AppError { ) } + pub fn ai_model_invalid() -> Self { + Self::new( + "AI_MODEL_INVALID", + "Model is not valid", + "The model file or manifest failed verification.", + ) + .with_suggestion("Import the file again, or check that the checksum matches.") + } + + pub fn ai_model_not_found() -> Self { + Self::new( + "AI_MODEL_NOT_FOUND", + "Model not found", + "No installed model matches that checksum.", + ) + } + pub fn io(context: &str, err: impl std::fmt::Display) -> Self { Self::new( "IO_ERROR", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 40e2955..9bf294f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,6 +30,17 @@ //! //! Jobs (`commands::jobs`): //! - `cancel_job(registry, job_id: String) -> Result<(), AppError>` +//! +//! Local model setup (`commands::ai`) — paths in, never file bytes: +//! - `ai_pick_model_file() -> Result, AppError>` +//! - `ai_preview_model(path) -> Result` +//! - `ai_import_model(path, expected_sha256) -> Result` +//! - `ai_list_models() -> Result` +//! - `ai_load_model(checksum) -> Result<(), AppError>` +//! - `ai_unload_model() -> Result<(), AppError>` +//! - `ai_cancel() -> Result<(), AppError>` +//! - `ai_generate(prompt) -> Result` +//! - `ai_remove_model(checksum) -> Result` mod ai; mod commands; @@ -38,9 +49,16 @@ mod models; mod pdf_engine; mod utils; +#[cfg(test)] +mod ai_lifecycle_tests; +#[cfg(test)] +mod ai_store_tests; #[cfg(test)] mod ai_tests; +use std::sync::Arc; + +use commands::ai::AiSession; use models::JobRegistry; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -52,6 +70,8 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) // Shared, cancellable job registry. .manage(JobRegistry::default()) + // In-process Fake + selected checksum. Store on disk is the source of truth. + .manage(Arc::new(AiSession::new())) .invoke_handler(tauri::generate_handler![ // files / system commands::files::pick_pdf_files, @@ -87,6 +107,16 @@ pub fn run() { commands::pdf::nup_pdf, // jobs commands::jobs::cancel_job, + // local model setup (paths in, never file bytes) + commands::ai::ai_pick_model_file, + commands::ai::ai_preview_model, + commands::ai::ai_import_model, + commands::ai::ai_list_models, + commands::ai::ai_load_model, + commands::ai::ai_unload_model, + commands::ai::ai_cancel, + commands::ai::ai_generate, + commands::ai::ai_remove_model, // page preview / review commands::render::renderer_available, commands::render::render_thumbnails, diff --git a/src/features/settings/LocalModelSection.tsx b/src/features/settings/LocalModelSection.tsx new file mode 100644 index 0000000..e9f93fe --- /dev/null +++ b/src/features/settings/LocalModelSection.tsx @@ -0,0 +1,279 @@ +import { useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Icon } from "@/components/ui/Icon"; +import { Modal } from "@/components/ui/Modal"; +import { useToast } from "@/components/ui/Toast"; +import { formatBytes } from "@/lib/formatBytes"; +import { + aiImportModel, + aiListModels, + aiLoadModel, + aiPickModelFile, + aiPreviewModel, + aiRemoveModel, + aiUnloadModel, +} from "@/lib/tauriCommands"; +import { toAppError, type ModelPreview, type ModelSetupSnapshot } from "@/lib/types"; +import { modelSetupView } from "./modelSetupView"; + +const EMPTY_SNAPSHOT: ModelSetupSnapshot = { + ready: [], + backendStatus: "Unloaded", + health: { ok: false, backendId: "fake" }, + selectedChecksum: null, + storeRoot: "", +}; + +export function LocalModelSection() { + const { toast } = useToast(); + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); + const [preview, setPreview] = useState(null); + const [previewPath, setPreviewPath] = useState(null); + const [pendingRemove, setPendingRemove] = useState<{ checksum: string; size: number } | null>( + null, + ); + const [busy, setBusy] = useState(false); + const cancelledRef = useRef(false); + + const view = modelSetupView({ + snapshot, + preview: preview ?? undefined, + pendingRemove: pendingRemove ?? undefined, + }); + + const refresh = async () => { + setSnapshot(await aiListModels()); + }; + + useEffect(() => { + let on = true; + aiListModels() + .then((next) => { + if (on) setSnapshot(next); + }) + .catch((err) => { + if (!on) return; + toast({ + title: "Could not list models", + description: toAppError(err).message, + variant: "error", + }); + }); + return () => { + on = false; + }; + }, [toast]); + + const run = async (title: string, work: () => Promise) => { + if (busy) return; + cancelledRef.current = false; + setBusy(true); + try { + await work(); + } catch (err) { + if (cancelledRef.current) return; + toast({ title, description: toAppError(err).message, variant: "error" }); + } finally { + setBusy(false); + } + }; + + const clearPreview = () => { + setPreview(null); + setPreviewPath(null); + }; + + const pickPreview = async () => { + const path = await aiPickModelFile(); + if (!path || cancelledRef.current) return; + const facts = await aiPreviewModel(path); + if (cancelledRef.current) return; + setPreview(facts); + setPreviewPath(path); + }; + + const onImport = () => + run("Could not import model", async () => { + if (preview && previewPath) { + try { + await aiImportModel(previewPath, preview.sha256); + } catch (err) { + if (cancelledRef.current) return; + const appErr = toAppError(err); + if ( + appErr.code === "AI_MODEL_INVALID" || + /checksum/i.test(`${appErr.message} ${appErr.details ?? ""}`) + ) { + clearPreview(); + } + throw err; + } + clearPreview(); + await refresh(); + if (cancelledRef.current) return; + toast({ + title: "Model imported", + description: "The file was copied into OffPDF app data.", + variant: "success", + }); + return; + } + await pickPreview(); + }); + + const onChooseDifferent = () => + run("Could not preview model", async () => { + clearPreview(); + await pickPreview(); + }); + + const onLoad = (checksum: string) => + run("Could not load model", async () => { + await aiLoadModel(checksum); + await refresh(); + }); + + const onUnload = () => + run("Could not unload model", async () => { + await aiUnloadModel(); + await refresh(); + }); + + const onCancel = () => { + cancelledRef.current = true; + clearPreview(); + }; + + const onConfirmRemove = () => + run("Could not remove model", async () => { + if (!pendingRemove) return; + const result = await aiRemoveModel(pendingRemove.checksum); + setPendingRemove(null); + if (preview?.sha256 === result.checksum) { + clearPreview(); + } + await refresh(); + toast({ + title: "Model removed", + description: + result.recoveredBytes > 0 + ? `Freed ${formatBytes(result.recoveredBytes)}.` + : "Nothing to clear.", + variant: "success", + }); + }); + + const facts = view.facts; + const loaded = snapshot.backendStatus === "Ready"; + + return ( + +
+
+
Local model
+
+ Optional. Import a file from this computer. Nothing is downloaded and PDF tools stay + available with no model installed. +
+
+ {snapshot.backendStatus} +
+ + {view.showFacts && ( +
+
+
Size
+
{facts ? formatBytes(facts.size) : "—"}
+
+
+
License
+
{facts?.license ?? "—"}
+
+
+
Location
+
{facts?.location ?? "—"}
+
+
+
Compatibility
+
+ {facts?.compatibility ?? "Hardware check is not available in this version."} +
+
+
+ )} + + {snapshot.ready.length > 0 && ( +
    + {snapshot.ready.map((model) => { + const isSelected = + loaded && snapshot.selectedChecksum === model.checksum; + return ( +
  • +
    +
    + {model.checksum.slice(0, 8)} + {formatBytes(model.size)} +
    +
    {model.checksum}
    +
    +
    + {isSelected ? ( + + ) : ( + + )} + +
    +
  • + ); + })} +
+ )} + +
+ + {preview && ( + + )} + +
+ + setPendingRemove(null)} + title="Remove imported model?" + footer={ + <> + + + + } + > +

{view.confirmRemove}

+
+
+ ); +} diff --git a/src/features/settings/modelSetupView.test.ts b/src/features/settings/modelSetupView.test.ts new file mode 100644 index 0000000..bc87550 --- /dev/null +++ b/src/features/settings/modelSetupView.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { formatBytes } from "@/lib/formatBytes"; +import { TOOLS } from "@/lib/tools"; + +const FIXTURE_SHA256 = + "9a32fd0b4e68056601b986dc617f8d3be37fe0af3903d98cdda02fff552c884e"; +const FIXTURE_SIZE = 23; +const HARDWARE_STUB = "Hardware check is not available in this version."; +const STORE_LOCATION = `/tmp/offpdf-test-store/blobs/${FIXTURE_SHA256}`; + +async function loadView() { + return import("./modelSetupView"); +} + +describe("setup-zero-models-tools-ok", () => { + it("keeps all 23 PDF tools registered with zero models", () => { + expect(TOOLS).toHaveLength(23); + expect(TOOLS.some((tool) => tool.id === "merge")).toBe(true); + expect(TOOLS.some((tool) => tool.id === "ocr")).toBe(true); + expect(TOOLS.every((tool) => tool.path.startsWith("/tools/"))).toBe(true); + }); +}); + +describe("setup-facts-before-import", () => { + it("empty snapshot still shows a facts block and an Import CTA, with no download URL or auto-import", async () => { + const { modelSetupView } = await loadView(); + const view = modelSetupView({ + snapshot: { ready: [], backendStatus: "Unloaded" }, + }); + + expect(view.showFacts).toBe(true); + expect(view.importCta).toMatch(/import/i); + expect(view.downloadUrl ?? null).toBeNull(); + expect(view.autoImport).toBe(false); + }); + + it("preview facts include size, license, location, and the hardware stub before imported is true", async () => { + const { modelSetupView } = await loadView(); + const view = modelSetupView({ + snapshot: { ready: [], backendStatus: "Unloaded" }, + preview: { + size: FIXTURE_SIZE, + sha256: FIXTURE_SHA256, + license: "imported", + runtimeCompat: "any", + location: STORE_LOCATION, + }, + }); + + expect(view.facts).toBeTruthy(); + expect(view.facts?.size).toBe(FIXTURE_SIZE); + expect(view.facts?.license).toBe("imported"); + expect(view.facts?.location).toBe(STORE_LOCATION); + expect(view.facts?.compatibility).toContain("any"); + expect(view.facts?.compatibility).toContain(HARDWARE_STUB); + expect(view.facts?.imported).toBe(false); + expect(view.downloadUrl ?? null).toBeNull(); + expect(view.autoImport).toBe(false); + }); +}); + +describe("setup-facts-wire-compat", () => { + it("live DTO compatibility contains the hardware stub once and does not start with any — any", async () => { + const { modelSetupView } = await loadView(); + const view = modelSetupView({ + snapshot: { ready: [], backendStatus: "Unloaded" }, + preview: { + size: FIXTURE_SIZE, + license: "imported", + location: STORE_LOCATION, + runtimeCompat: "any", + compatibility: `any. ${HARDWARE_STUB}`, + }, + }); + + const compatibility = view.facts?.compatibility ?? ""; + expect(compatibility.split(HARDWARE_STUB).length - 1).toBe(1); + expect(compatibility.startsWith("any — any")).toBe(false); + expect(view.showFacts).toBe(true); + expect(view.importCta).toMatch(/import/i); + }); +}); + +describe("setup-preview-can-rechoose", () => { + it("preview keeps the facts panel and an Import CTA so another file can be chosen", async () => { + const { modelSetupView } = await loadView(); + const view = modelSetupView({ + snapshot: { ready: [], backendStatus: "Unloaded" }, + preview: { + size: FIXTURE_SIZE, + license: "imported", + location: STORE_LOCATION, + runtimeCompat: "any", + compatibility: `any. ${HARDWARE_STUB}`, + }, + }); + + expect(view.showFacts).toBe(true); + expect(view.importCta).toMatch(/import/i); + expect(view.downloadUrl ?? null).toBeNull(); + expect(view.autoImport).toBe(false); + }); +}); + +describe("setup-import-explicit-only", () => { + it("empty-store view has no download URL and does not auto-import", async () => { + const { modelSetupView } = await loadView(); + const view = modelSetupView({ + snapshot: { ready: [] }, + }); + + expect(view.downloadUrl ?? null).toBeNull(); + expect(view.autoImport).toBe(false); + expect(view.importCta).toMatch(/import/i); + }); +}); + +describe("setup-remove-reports-bytes", () => { + it("remove-confirm copy names the checksum prefix and formatBytes(size)", async () => { + const { modelSetupView } = await loadView(); + const view = modelSetupView({ + snapshot: { + ready: [{ checksum: FIXTURE_SHA256, size: FIXTURE_SIZE }], + backendStatus: "Unloaded", + }, + pendingRemove: { checksum: FIXTURE_SHA256, size: FIXTURE_SIZE }, + }); + + expect(view.confirmRemove).toBeTruthy(); + expect(view.confirmRemove).toContain(FIXTURE_SHA256.slice(0, 8)); + expect(view.confirmRemove).toContain(formatBytes(FIXTURE_SIZE)); + }); +}); diff --git a/src/features/settings/modelSetupView.ts b/src/features/settings/modelSetupView.ts new file mode 100644 index 0000000..900a8c7 --- /dev/null +++ b/src/features/settings/modelSetupView.ts @@ -0,0 +1,86 @@ +import { formatBytes } from "@/lib/formatBytes"; + +const HARDWARE_STUB = "Hardware check is not available in this version."; + +export interface ModelSetupReady { + checksum: string; + size: number; + license?: string; + runtimeCompat?: string; +} + +export interface ModelSetupSnapshotInput { + ready: ModelSetupReady[]; + backendStatus?: string; + storeRoot?: string; + selectedChecksum?: string | null; +} + +export interface ModelSetupPreviewInput { + size: number; + sha256?: string; + license: string; + runtimeCompat: string; + location: string; + compatibility?: string; +} + +export interface ModelSetupPendingRemove { + checksum: string; + size: number; +} + +export interface ModelSetupFacts { + size: number; + license: string; + location: string; + compatibility: string; + imported: boolean; +} + +export interface ModelSetupView { + showFacts: boolean; + importCta: string; + downloadUrl: string | null; + autoImport: boolean; + facts?: ModelSetupFacts; + confirmRemove?: string; +} + +export function modelSetupView(input: { + snapshot: ModelSetupSnapshotInput; + preview?: ModelSetupPreviewInput; + pendingRemove?: ModelSetupPendingRemove; +}): ModelSetupView { + const preview = input.preview; + const facts = preview + ? { + size: preview.size, + license: preview.license, + location: preview.location, + compatibility: composeCompatibility(preview.runtimeCompat, preview.compatibility), + imported: false, + } + : undefined; + + const pending = input.pendingRemove; + const confirmRemove = pending + ? `Remove model ${pending.checksum.slice(0, 8)} (${formatBytes(pending.size)})? This deletes the imported copy from OffPDF app data.` + : undefined; + + return { + showFacts: true, + importCta: preview ? "Import this file" : "Import", + downloadUrl: null, + autoImport: false, + facts, + confirmRemove, + }; +} + +function composeCompatibility(runtimeCompat: string, compatibility?: string): string { + const composed = compatibility?.trim(); + if (composed) return composed; + const runtime = runtimeCompat.trim(); + return runtime ? `${runtime} — ${HARDWARE_STUB}` : HARDWARE_STUB; +} diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index f2371db..b768916 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -26,6 +26,10 @@ import type { RotateGroup, RotationAngle, SplitMode, + ModelManifest, + ModelPreview, + ModelRemoveResult, + ModelSetupSnapshot, } from "./types"; import type { EditDocument, FormField, FormValue } from "./editor"; @@ -546,6 +550,55 @@ export function cancelJob(jobId: string): Promise { return invoke("cancel_job", { jobId }); } +// --------------------------------------------------------------------------- +// Local model setup — paths in, never file bytes. Import-only; no download. +// --------------------------------------------------------------------------- + +/** Native "All files" picker. `null` if the user cancelled. */ +export function aiPickModelFile(): Promise { + return invoke("ai_pick_model_file"); +} + +/** Hash a chosen path. Does not write a blob. */ +export function aiPreviewModel(path: string): Promise { + return invoke("ai_preview_model", { path }); +} + +/** Copy the chosen file into the store after the user confirms the preview hash. */ +export function aiImportModel(path: string, expectedSha256: string): Promise { + return invoke("ai_import_model", { path, expectedSha256 }); +} + +/** Ready manifests on disk plus Fake status. Does not auto-load. */ +export function aiListModels(): Promise { + return invoke("ai_list_models"); +} + +/** Load a ready checksum into the Fake. */ +export function aiLoadModel(checksum: string): Promise { + return invoke("ai_load_model", { checksum }); +} + +/** Unload the Fake. Ready blobs stay on disk. */ +export function aiUnloadModel(): Promise { + return invoke("ai_unload_model"); +} + +/** Cooperative cancel of an in-flight generate. */ +export function aiCancel(): Promise { + return invoke("ai_cancel"); +} + +/** Prompt-only generate for tests / later assistant work. No PDF path. */ +export function aiGenerate(prompt: string): Promise { + return invoke("ai_generate", { prompt }); +} + +/** Delete one imported model and report recovered bytes. */ +export function aiRemoveModel(checksum: string): Promise { + return invoke("ai_remove_model", { checksum }); +} + /** * Subscribe to job progress updates. Returns an unlisten function. * Pass a `jobId` to receive only that job's updates. diff --git a/src/lib/types.ts b/src/lib/types.ts index a660ec2..3b060a7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -192,6 +192,46 @@ export interface RecentJob { error?: string; } +/** Ready local-model manifest (Rust `ModelManifest`, camelCase on the wire). */ +export interface ModelManifest { + schemaVersion: number; + size: number; + license: string; + runtimeCompat: string; + checksum: string; +} + +/** Preview facts for a chosen file before import (Rust `ModelPreviewDto`). */ +export interface ModelPreview { + size: number; + sha256: string; + license: string; + runtimeCompat: string; + location: string; + compatibility: string; +} + +/** Backend health from `ai_list_models` (Rust `ModelHealthDto`). */ +export interface ModelHealth { + ok: boolean; + backendId: string; +} + +/** Store + Fake status. Restart lists ready blobs and Unloaded Fake. */ +export interface ModelSetupSnapshot { + ready: ModelManifest[]; + backendStatus: "Unloaded" | "Ready" | string; + health: ModelHealth; + selectedChecksum: string | null; + storeRoot: string; +} + +/** Result of `ai_remove_model` (Rust `ModelRemoveResultDto`). */ +export interface ModelRemoveResult { + checksum: string; + recoveredBytes: number; +} + /** Type guard: is this value an AppError coming back from `invoke`? */ export function isAppError(value: unknown): value is AppError { if (typeof value !== "object" || value === null) { diff --git a/src/styles/global.css b/src/styles/global.css index f5a6943..c45cdfb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -991,6 +991,35 @@ button { .setting-row__label { font-weight: 600; } .setting-row__desc { color: var(--text-muted); font-size: 12.5px; margin-top: 2px; } +.model-facts { + display: grid; + gap: 8px; + margin: 4px 0 12px; +} +.model-facts__row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + font-size: 13px; +} +.model-facts__row dt { color: var(--text-muted); } +.model-facts__row dd { margin: 0; word-break: break-all; } +.model-ready-list { + list-style: none; + margin: 0 0 12px; + padding: 0; +} +.model-ready { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 0; + border-top: 1px solid var(--border); +} +.model-ready__size { color: var(--text-muted); font-weight: 400; } +.model-actions { padding-top: 4px; } + /* ========================================================================= Page thumbnails / visual picker ========================================================================= */