diff --git a/README.md b/README.md index ea926d81..97e37cbf 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Thuki utilizes a **Dual-Layer Isolation** model for generative inference: 3. **Start Sandbox (Security-First Launch)**: Thuki offers a hardened, isolated Docker sandbox as a secure-by-default environment for generative inference. This is ideal if you do not wish to install AI models directly on your host or prefer maximum isolation from the network. - This bootstraps the sandbox and pulls the models (default: `llama3.2:3b`). + This bootstraps the sandbox and pulls the models (default: `gemma3:4b`). ```bash bun run sandbox:start diff --git a/sandbox/docker-compose.yml b/sandbox/docker-compose.yml index cc1fb6d2..40df3cfe 100644 --- a/sandbox/docker-compose.yml +++ b/sandbox/docker-compose.yml @@ -40,7 +40,7 @@ services: # and graceful signal handling (pkill) without requiring a custom Dockerfile. entrypoint: ["/usr/bin/bash", "-c"] environment: - - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2:3b} + - OLLAMA_MODEL=${OLLAMA_MODEL:-gemma3:4b} volumes: - sandbox_models:/root/.ollama command: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0ab09cb8..1b16991e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -367,6 +367,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colored" version = "3.1.1" @@ -1166,6 +1172,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1436,6 +1452,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -1668,9 +1690,24 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -2780,6 +2817,12 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3885,6 +3928,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "image", "jni", "libc", @@ -4160,11 +4204,13 @@ dependencies = [ name = "thuki" version = "0.1.0" dependencies = [ + "base64 0.22.1", "core-foundation 0.10.1", "core-graphics", "dirs", "dotenvy", "futures-util", + "image", "mockito", "reqwest", "rusqlite", @@ -4921,6 +4967,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5667,3 +5719,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f3fdb65d..a12691bf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] } +tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "protocol-asset"] } serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.13.2", features = ["json", "stream"] } @@ -29,6 +29,8 @@ dotenvy = "0.15.7" rusqlite = { version = "0.35", features = ["bundled"] } uuid = { version = "1", features = ["v4"] } dirs = "6" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +base64 = "0.22" [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b3d44af2..a4d1f3fe 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken; /// Default configuration constants as the application currently lacks a Settings UI. pub const DEFAULT_OLLAMA_URL: &str = "http://127.0.0.1:11434"; -pub const DEFAULT_MODEL_NAME: &str = "llama3.2:3b"; +pub const DEFAULT_MODEL_NAME: &str = "gemma3:4b"; const DEFAULT_SYSTEM_PROMPT: &str = "You are Thuki (thư ký), a personal desktop secretary that \ lives as a floating overlay on macOS. You are fast, sharp, and helpful.\n\nResponse style:\n- Be \ concise. You appear in a small floating window — keep responses scannable.\n- Use short paragraphs, \ @@ -35,10 +35,16 @@ pub enum StreamChunk { } /// A single message in the Ollama `/api/chat` conversation format. +/// +/// The optional `images` field carries base64-encoded image data for +/// multimodal models (e.g. `gemma3:4b`). When absent or empty, the +/// message is text-only. #[derive(Clone, Serialize, Deserialize)] pub struct ChatMessage { pub role: String, pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub images: Option>, } /// Request payload for Ollama `/api/chat` endpoint. @@ -234,13 +240,16 @@ pub async fn stream_ollama_chat( } /// Streams a chat response from the local Ollama backend. Appends the user -/// message and assistant response to conversation history only after successful -/// completion. Uses an epoch counter to prevent stale writes after a reset. +/// message and assistant response to conversation history after completion +/// or cancellation (retaining context for follow-up requests). Uses an epoch +/// counter to prevent stale writes after a reset. #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] +#[allow(clippy::too_many_arguments)] pub async fn ask_ollama( message: String, quoted_text: Option, + image_paths: Option>, on_event: Channel, client: State<'_, reqwest::Client>, generation: State<'_, GenerationState>, @@ -257,20 +266,31 @@ pub async fn ask_ollama( _ => message, }; + // Base64-encode attached images for the Ollama multimodal API. + let images = match image_paths { + Some(ref paths) if !paths.is_empty() => { + Some(crate::images::encode_images_as_base64(paths)?) + } + _ => None, + }; + let user_msg = ChatMessage { role: "user".to_string(), content, + images, }; // Snapshot the current epoch and build the messages array for Ollama. // The user message is NOT yet committed to history — it is only added - // after a successful response to prevent orphaned messages on errors. + // after a response (including partial/cancelled) to prevent orphaned + // messages on errors. let (epoch_at_start, messages) = { let conv = history.messages.lock().unwrap(); let epoch = history.epoch.load(Ordering::SeqCst); let mut msgs = vec![ChatMessage { role: "system".to_string(), content: system_prompt.0.clone(), + images: None, }]; msgs.extend(conv.clone()); msgs.push(user_msg.clone()); @@ -296,10 +316,17 @@ pub async fn ask_ollama( let current_epoch = history.epoch.load(Ordering::SeqCst); if current_epoch == epoch_at_start && !accumulated.is_empty() { let mut conv = history.messages.lock().unwrap(); - conv.push(user_msg); + // Strip images from the persisted context — only the current turn's + // images are sent to Ollama; replaying base64 blobs on every + // subsequent turn would balloon payload size unnecessarily. + conv.push(ChatMessage { + images: None, + ..user_msg + }); conv.push(ChatMessage { role: "assistant".to_string(), content: accumulated, + images: None, }); } @@ -371,6 +398,7 @@ mod tests { let messages = vec![ChatMessage { role: "user".to_string(), content: "hi".to_string(), + images: None, }]; let accumulated = stream_ollama_chat( @@ -804,10 +832,12 @@ mod tests { ChatMessage { role: "system".to_string(), content: "Be helpful".to_string(), + images: None, }, ChatMessage { role: "user".to_string(), content: "hi".to_string(), + images: None, }, ]; @@ -899,8 +929,14 @@ mod tests { assert!(second_clone.is_cancelled()); } + /// Guard to serialize tests that mutate `THUKI_SYSTEM_PROMPT` env var. + /// Rust runs tests in parallel by default; without serialization these + /// tests race on the shared environment variable. + static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + #[test] fn load_system_prompt_returns_default_when_unset() { + let _guard = ENV_LOCK.lock().unwrap(); std::env::remove_var("THUKI_SYSTEM_PROMPT"); let prompt = load_system_prompt(); @@ -909,6 +945,7 @@ mod tests { #[test] fn load_system_prompt_reads_env_var() { + let _guard = ENV_LOCK.lock().unwrap(); std::env::set_var("THUKI_SYSTEM_PROMPT", "Custom prompt"); let prompt = load_system_prompt(); @@ -919,6 +956,7 @@ mod tests { #[test] fn load_system_prompt_ignores_empty_env_var() { + let _guard = ENV_LOCK.lock().unwrap(); std::env::set_var("THUKI_SYSTEM_PROMPT", " "); let prompt = load_system_prompt(); @@ -940,6 +978,7 @@ mod tests { h.messages.lock().unwrap().push(ChatMessage { role: "user".to_string(), content: "hi".to_string(), + images: None, }); h.epoch.fetch_add(1, Ordering::SeqCst); diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 3b4c0470..f8733031 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -29,20 +29,27 @@ pub struct PersistedMessage { pub role: String, pub content: String, pub quoted_text: Option, + pub image_paths: Option, pub created_at: i64, } -/// Opens (or creates) the SQLite database at `~/.thuki/thuki.db` and runs -/// migrations. Returns the ready-to-use connection. +/// Opens (or creates) the SQLite database at `/thuki.db` and +/// runs migrations. If an existing database is found at the legacy location +/// (`~/.thuki/thuki.db`), it is moved to the new location automatically. /// /// # Errors /// -/// Returns an error if the home directory cannot be determined, the -/// `~/.thuki/` directory cannot be created, or SQLite initialisation fails. +/// Returns an error if the data directory cannot be created or SQLite +/// initialisation fails. #[cfg_attr(coverage_nightly, coverage(off))] -pub fn open_database() -> SqlResult { - let db_path = - resolve_db_path().map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?; +pub fn open_database(app_data_dir: &std::path::Path) -> SqlResult { + std::fs::create_dir_all(app_data_dir) + .map_err(|e| rusqlite::Error::InvalidParameterName(e.to_string()))?; + + let db_path = app_data_dir.join("thuki.db"); + + // One-time migration: move database from the legacy ~/.thuki/ location. + migrate_legacy_db(&db_path); let conn = Connection::open(&db_path)?; conn.execute_batch("PRAGMA journal_mode = WAL;")?; @@ -61,45 +68,75 @@ pub fn open_in_memory() -> SqlResult { Ok(conn) } -/// Resolves the database file path, creating `~/.thuki/` if it does not exist. +/// Moves the database from `~/.thuki/thuki.db` to the Tauri app data +/// directory if the legacy file exists and the target does not. #[cfg_attr(coverage_nightly, coverage(off))] -fn resolve_db_path() -> std::io::Result { - let home = dirs::home_dir().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") - })?; - let dir = home.join(".thuki"); - std::fs::create_dir_all(&dir)?; - Ok(dir.join("thuki.db")) +fn migrate_legacy_db(new_path: &std::path::Path) { + if new_path.exists() { + return; + } + let legacy_path = match dirs::home_dir() { + Some(home) => home.join(".thuki").join("thuki.db"), + None => return, + }; + if !legacy_path.exists() { + return; + } + // Move the database file. If the move fails (e.g. cross-device), fall + // back to copy + delete so the migration succeeds across filesystem + // boundaries. + if std::fs::rename(&legacy_path, new_path).is_err() + && std::fs::copy(&legacy_path, new_path).is_ok() + { + let _ = std::fs::remove_file(&legacy_path); + } + // Also move the WAL and SHM journal files if they exist. + for ext in &["-wal", "-shm"] { + let legacy_journal = legacy_path.with_extension(format!("db{ext}")); + if legacy_journal.exists() { + let new_journal = new_path.with_extension(format!("db{ext}")); + if std::fs::rename(&legacy_journal, &new_journal).is_err() + && std::fs::copy(&legacy_journal, &new_journal).is_ok() + { + let _ = std::fs::remove_file(&legacy_journal); + } + } + } } /// Creates the schema tables if they do not already exist. fn run_migrations(conn: &Connection) -> SqlResult<()> { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS conversations ( - id TEXT PRIMARY KEY, - title TEXT, - model TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - meta TEXT - ); - - CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL - REFERENCES conversations(id) ON DELETE CASCADE, - role TEXT NOT NULL, - content TEXT NOT NULL, - quoted_text TEXT, - created_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_messages_conversation - ON messages(conversation_id, created_at); - - CREATE INDEX IF NOT EXISTS idx_conversations_updated - ON conversations(updated_at DESC);", - ) + // Static schema DDL — compiled into a single &str at build time via concat!. + const SCHEMA_DDL: &str = concat!( + "CREATE TABLE IF NOT EXISTS conversations (", + " id TEXT PRIMARY KEY, title TEXT, model TEXT NOT NULL,", + " created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, meta TEXT);", + "CREATE TABLE IF NOT EXISTS messages (", + " id TEXT PRIMARY KEY,", + " conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,", + " role TEXT NOT NULL, content TEXT NOT NULL, quoted_text TEXT,", + " created_at INTEGER NOT NULL);", + "CREATE INDEX IF NOT EXISTS idx_messages_conversation", + " ON messages(conversation_id, created_at);", + "CREATE INDEX IF NOT EXISTS idx_conversations_updated", + " ON conversations(updated_at DESC);", + ); + conn.execute_batch(SCHEMA_DDL)?; + + // Migration: add image_paths column to messages table. + // ALTER TABLE with IF NOT EXISTS is not supported in SQLite, so we check + // the column existence via pragma and only add if missing. + let has_image_paths: bool = conn + .prepare("PRAGMA table_info(messages)")? + .query_map([], |row| row.get::<_, String>(1))? + .filter_map(|r| r.ok()) + .any(|name| name == "image_paths"); + + if !has_image_paths { + conn.execute_batch("ALTER TABLE messages ADD COLUMN image_paths TEXT;")?; + } + + Ok(()) } // ─── Conversation CRUD ────────────────────────────────────────────────────── @@ -191,13 +228,14 @@ pub fn insert_message( role: &str, content: &str, quoted_text: Option<&str>, + image_paths: Option<&str>, ) -> SqlResult { let id = uuid::Uuid::new_v4().to_string(); let now = now_millis(); conn.execute( - "INSERT INTO messages (id, conversation_id, role, content, quoted_text, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![id, conversation_id, role, content, quoted_text, now], + "INSERT INTO messages (id, conversation_id, role, content, quoted_text, image_paths, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, conversation_id, role, content, quoted_text, image_paths, now], )?; conn.execute( "UPDATE conversations SET updated_at = ?1 WHERE id = ?2", @@ -211,16 +249,16 @@ pub fn insert_message( pub fn insert_messages_batch( conn: &Connection, conversation_id: &str, - messages: &[(String, String, Option)], // (role, content, quoted_text) + messages: &[(String, String, Option, Option)], // (role, content, quoted_text, image_paths) ) -> SqlResult<()> { let tx = conn.unchecked_transaction()?; let now = now_millis(); { let mut stmt = tx.prepare( - "INSERT INTO messages (id, conversation_id, role, content, quoted_text, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO messages (id, conversation_id, role, content, quoted_text, image_paths, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", )?; - for (role, content, quoted_text) in messages { + for (role, content, quoted_text, image_paths) in messages { let id = uuid::Uuid::new_v4().to_string(); stmt.execute(params![ id, @@ -228,6 +266,7 @@ pub fn insert_messages_batch( role, content, quoted_text.as_deref(), + image_paths.as_deref(), now ])?; } @@ -243,7 +282,7 @@ pub fn insert_messages_batch( #[cfg_attr(coverage_nightly, coverage(off))] pub fn load_messages(conn: &Connection, conversation_id: &str) -> SqlResult> { let mut stmt = conn.prepare( - "SELECT id, role, content, quoted_text, created_at + "SELECT id, role, content, quoted_text, image_paths, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC", @@ -254,12 +293,30 @@ pub fn load_messages(conn: &Connection, conversation_id: &str) -> SqlResult SqlResult> { + let mut stmt = + conn.prepare("SELECT image_paths FROM messages WHERE image_paths IS NOT NULL")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + + let mut paths = Vec::new(); + for row in rows { + let json_str = row?; + if let Ok(arr) = serde_json::from_str::>(&json_str) { + paths.extend(arr); + } + } + Ok(paths) +} + // ─── Helpers ──────────────────────────────────────────────────────────────── /// Maps a row from the conversations query to a `ConversationSummary`. @@ -286,6 +343,7 @@ fn now_millis() -> i64 { #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn migrations_create_tables() { @@ -312,21 +370,21 @@ mod tests { #[test] fn create_and_list_conversations() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, Some("Test Chat"), "llama3.2:3b").unwrap(); + let id = create_conversation(&conn, Some("Test Chat"), "gemma3:4b").unwrap(); assert!(!id.is_empty()); let convos = list_conversations(&conn, None).unwrap(); assert_eq!(convos.len(), 1); assert_eq!(convos[0].title.as_deref(), Some("Test Chat")); - assert_eq!(convos[0].model, "llama3.2:3b"); + assert_eq!(convos[0].model, "gemma3:4b"); assert_eq!(convos[0].message_count, 0); } #[test] fn list_conversations_with_search_filter() { let conn = open_in_memory().unwrap(); - create_conversation(&conn, Some("Rust Code Help"), "llama3.2:3b").unwrap(); - create_conversation(&conn, Some("Draft Email"), "llama3.2:3b").unwrap(); + create_conversation(&conn, Some("Rust Code Help"), "gemma3:4b").unwrap(); + create_conversation(&conn, Some("Draft Email"), "gemma3:4b").unwrap(); let results = list_conversations(&conn, Some("rust")).unwrap(); assert_eq!(results.len(), 1); @@ -340,8 +398,8 @@ mod tests { #[test] fn search_escapes_sql_wildcards() { let conn = open_in_memory().unwrap(); - create_conversation(&conn, Some("100% done"), "llama3.2:3b").unwrap(); - create_conversation(&conn, Some("something else"), "llama3.2:3b").unwrap(); + create_conversation(&conn, Some("100% done"), "gemma3:4b").unwrap(); + create_conversation(&conn, Some("something else"), "gemma3:4b").unwrap(); let results = list_conversations(&conn, Some("100%")).unwrap(); assert_eq!(results.len(), 1); @@ -351,7 +409,7 @@ mod tests { #[test] fn update_conversation_title() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, Some("Old Title"), "llama3.2:3b").unwrap(); + let id = create_conversation(&conn, Some("Old Title"), "gemma3:4b").unwrap(); super::update_conversation_title(&conn, &id, "New Title").unwrap(); @@ -362,9 +420,9 @@ mod tests { #[test] fn delete_conversation_cascades_messages() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, Some("To Delete"), "llama3.2:3b").unwrap(); - insert_message(&conn, &id, "user", "hello", None).unwrap(); - insert_message(&conn, &id, "assistant", "hi there", None).unwrap(); + let id = create_conversation(&conn, Some("To Delete"), "gemma3:4b").unwrap(); + insert_message(&conn, &id, "user", "hello", None, None).unwrap(); + insert_message(&conn, &id, "assistant", "hi there", None, None).unwrap(); delete_conversation(&conn, &id).unwrap(); @@ -378,10 +436,26 @@ mod tests { #[test] fn insert_and_load_messages() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, None, "llama3.2:3b").unwrap(); - - insert_message(&conn, &id, "user", "What is Rust?", Some("quoted context")).unwrap(); - insert_message(&conn, &id, "assistant", "Rust is a systems language.", None).unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); + + insert_message( + &conn, + &id, + "user", + "What is Rust?", + Some("quoted context"), + None, + ) + .unwrap(); + insert_message( + &conn, + &id, + "assistant", + "Rust is a systems language.", + None, + None, + ) + .unwrap(); let msgs = load_messages(&conn, &id).unwrap(); assert_eq!(msgs.len(), 2); @@ -396,15 +470,16 @@ mod tests { #[test] fn insert_messages_batch_is_atomic() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, None, "llama3.2:3b").unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); let batch = vec![ - ("user".to_string(), "hello".to_string(), None), - ("assistant".to_string(), "hi".to_string(), None), + ("user".to_string(), "hello".to_string(), None, None), + ("assistant".to_string(), "hi".to_string(), None, None), ( "user".to_string(), "how are you?".to_string(), Some("context".to_string()), + None, ), ]; insert_messages_batch(&conn, &id, &batch).unwrap(); @@ -421,13 +496,13 @@ mod tests { #[test] fn insert_message_touches_updated_at() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, None, "llama3.2:3b").unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); let before = list_conversations(&conn, None).unwrap()[0].updated_at; // Small delay to ensure timestamp changes. std::thread::sleep(std::time::Duration::from_millis(5)); - insert_message(&conn, &id, "user", "test", None).unwrap(); + insert_message(&conn, &id, "user", "test", None, None).unwrap(); let after = list_conversations(&conn, None).unwrap()[0].updated_at; assert!(after >= before); @@ -436,9 +511,9 @@ mod tests { #[test] fn conversations_ordered_by_most_recent() { let conn = open_in_memory().unwrap(); - let id1 = create_conversation(&conn, Some("First"), "llama3.2:3b").unwrap(); + let id1 = create_conversation(&conn, Some("First"), "gemma3:4b").unwrap(); std::thread::sleep(std::time::Duration::from_millis(5)); - create_conversation(&conn, Some("Second"), "llama3.2:3b").unwrap(); + create_conversation(&conn, Some("Second"), "gemma3:4b").unwrap(); let convos = list_conversations(&conn, None).unwrap(); assert_eq!(convos[0].title.as_deref(), Some("Second")); @@ -446,7 +521,7 @@ mod tests { // Updating a message in the first conversation bumps it to the top. std::thread::sleep(std::time::Duration::from_millis(5)); - insert_message(&conn, &id1, "user", "bump", None).unwrap(); + insert_message(&conn, &id1, "user", "bump", None, None).unwrap(); let convos = list_conversations(&conn, None).unwrap(); assert_eq!(convos[0].title.as_deref(), Some("First")); @@ -455,7 +530,7 @@ mod tests { #[test] fn create_conversation_with_no_title() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, None, "llama3.2:3b").unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); let convos = list_conversations(&conn, None).unwrap(); assert_eq!(convos.len(), 1); assert!(convos[0].title.is_none()); @@ -472,7 +547,7 @@ mod tests { #[test] fn load_messages_empty_conversation() { let conn = open_in_memory().unwrap(); - let id = create_conversation(&conn, None, "llama3.2:3b").unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); let msgs = load_messages(&conn, &id).unwrap(); assert!(msgs.is_empty()); } @@ -485,11 +560,137 @@ mod tests { } #[test] - fn resolve_db_path_creates_directory() { - // This test verifies the path resolution logic — it creates ~/.thuki/ - // which is acceptable in test environments. - let path = resolve_db_path().unwrap(); - assert!(path.ends_with("thuki.db")); - assert!(path.parent().unwrap().exists()); + fn insert_message_with_image_paths() { + let conn = open_in_memory().unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); + + let paths_json = r#"["/images/a.jpg","/images/b.jpg"]"#; + insert_message(&conn, &id, "user", "look at this", None, Some(paths_json)).unwrap(); + + let msgs = load_messages(&conn, &id).unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].image_paths.as_deref(), Some(paths_json)); + } + + #[test] + fn insert_message_without_image_paths() { + let conn = open_in_memory().unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); + + insert_message(&conn, &id, "user", "hello", None, None).unwrap(); + + let msgs = load_messages(&conn, &id).unwrap(); + assert_eq!(msgs.len(), 1); + assert!(msgs[0].image_paths.is_none()); + } + + #[test] + fn batch_insert_with_image_paths() { + let conn = open_in_memory().unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); + + let batch = vec![ + ( + "user".to_string(), + "look".to_string(), + None, + Some(r#"["/images/x.jpg"]"#.to_string()), + ), + ("assistant".to_string(), "I see".to_string(), None, None), + ]; + insert_messages_batch(&conn, &id, &batch).unwrap(); + + let msgs = load_messages(&conn, &id).unwrap(); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0].image_paths.as_deref(), Some(r#"["/images/x.jpg"]"#)); + assert!(msgs[1].image_paths.is_none()); + } + + #[test] + fn get_all_image_paths_collects_from_all_conversations() { + let conn = open_in_memory().unwrap(); + let c1 = create_conversation(&conn, None, "gemma3:4b").unwrap(); + let c2 = create_conversation(&conn, None, "gemma3:4b").unwrap(); + + insert_message( + &conn, + &c1, + "user", + "msg1", + None, + Some(r#"["/images/a.jpg"]"#), + ) + .unwrap(); + insert_message( + &conn, + &c2, + "user", + "msg2", + None, + Some(r#"["/images/b.jpg","/images/c.jpg"]"#), + ) + .unwrap(); + // Message without images. + insert_message(&conn, &c1, "assistant", "reply", None, None).unwrap(); + + let paths = get_all_image_paths(&conn).unwrap(); + assert_eq!(paths.len(), 3); + assert!(paths.contains(&"/images/a.jpg".to_string())); + assert!(paths.contains(&"/images/b.jpg".to_string())); + assert!(paths.contains(&"/images/c.jpg".to_string())); + } + + #[test] + fn get_all_image_paths_empty_when_no_images() { + let conn = open_in_memory().unwrap(); + let id = create_conversation(&conn, None, "gemma3:4b").unwrap(); + insert_message(&conn, &id, "user", "hello", None, None).unwrap(); + + let paths = get_all_image_paths(&conn).unwrap(); + assert!(paths.is_empty()); + } + + #[test] + fn migrate_legacy_db_moves_existing_file() { + let tmp = std::env::temp_dir().join(format!("thuki-migrate-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + + // Create a fake legacy DB file. + let legacy_dir = tmp.join("legacy"); + fs::create_dir_all(&legacy_dir).unwrap(); + let legacy_path = legacy_dir.join("thuki.db"); + fs::write(&legacy_path, b"legacy-data").unwrap(); + + // Target path where the DB should be migrated to. + let new_dir = tmp.join("new"); + fs::create_dir_all(&new_dir).unwrap(); + let new_path = new_dir.join("thuki.db"); + + // Manually test the migration logic (we can't call migrate_legacy_db + // directly because it hardcodes ~/.thuki, so we test the core logic). + assert!(!new_path.exists()); + if legacy_path.exists() && !new_path.exists() { + fs::rename(&legacy_path, &new_path).unwrap(); + } + assert!(new_path.exists()); + assert!(!legacy_path.exists()); + assert_eq!(fs::read(&new_path).unwrap(), b"legacy-data"); + + fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn migrate_legacy_db_skips_when_target_exists() { + let tmp = std::env::temp_dir().join(format!("thuki-migrate-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + + let new_path = tmp.join("thuki.db"); + fs::write(&new_path, b"existing-data").unwrap(); + + // When the target already exists, migration should be skipped. + migrate_legacy_db(&new_path); + assert_eq!(fs::read(&new_path).unwrap(), b"existing-data"); + + fs::remove_dir_all(&tmp).unwrap(); } } diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index 9dcd7383..4e734036 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -10,6 +10,7 @@ use std::sync::Mutex; use rusqlite::Connection; use serde::{Deserialize, Serialize}; +use tauri::Manager; use tauri::State; use crate::commands::{ChatMessage, ConversationHistory, SystemPrompt}; @@ -24,6 +25,7 @@ pub struct SaveMessagePayload { pub role: String, pub content: String, pub quoted_text: Option, + pub image_paths: Option>, } /// Response returned when saving a conversation. @@ -66,9 +68,14 @@ pub fn save_conversation( database::create_conversation(&conn, placeholder_title.as_deref(), &model) .map_err(|e| e.to_string())?; - let batch: Vec<(String, String, Option)> = messages + let batch: Vec<(String, String, Option, Option)> = messages .into_iter() - .map(|m| (m.role, m.content, m.quoted_text)) + .map(|m| { + let image_json = m.image_paths.filter(|v| !v.is_empty()).map(|v| { + serde_json::to_string(&v).expect("Vec serialization is infallible") + }); + (m.role, m.content, m.quoted_text, image_json) + }) .collect(); database::insert_messages_batch(&conn, &conversation_id, &batch).map_err(|e| e.to_string())?; @@ -84,15 +91,20 @@ pub fn persist_message( role: String, content: String, quoted_text: Option, + image_paths: Option>, db: State<'_, Database>, ) -> Result<(), String> { let conn = db.0.lock().map_err(|e| e.to_string())?; + let image_json = image_paths + .filter(|v| !v.is_empty()) + .map(|v| serde_json::to_string(&v).expect("Vec serialization is infallible")); database::insert_message( &conn, &conversation_id, &role, &content, quoted_text.as_deref(), + image_json.as_deref(), ) .map_err(|e| e.to_string())?; Ok(()) @@ -134,18 +146,45 @@ pub fn load_conversation( conv.push(ChatMessage { role: msg.role.clone(), content: msg.content.clone(), + images: None, }); } Ok(persisted) } -/// Deletes a conversation and all its messages from SQLite. +/// Deletes a conversation and all its messages from SQLite, and immediately +/// removes any image files referenced by those messages from disk. #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] -pub fn delete_conversation(conversation_id: String, db: State<'_, Database>) -> Result<(), String> { +pub fn delete_conversation( + app_handle: tauri::AppHandle, + conversation_id: String, + db: State<'_, Database>, +) -> Result<(), String> { let conn = db.0.lock().map_err(|e| e.to_string())?; - database::delete_conversation(&conn, &conversation_id).map_err(|e| e.to_string()) + + // Collect image paths before deleting messages (CASCADE will remove them). + let messages = database::load_messages(&conn, &conversation_id).map_err(|e| e.to_string())?; + let image_paths: Vec = messages + .iter() + .filter_map(|m| m.image_paths.as_ref()) + .filter_map(|json| serde_json::from_str::>(json).ok()) + .flatten() + .collect(); + + database::delete_conversation(&conn, &conversation_id).map_err(|e| e.to_string())?; + + // Best-effort file cleanup — don't fail the command if a file is missing. + let base_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + for path in &image_paths { + let _ = crate::images::remove_image(&base_dir, path); + } + + Ok(()) } /// Generates a short AI title for a saved conversation by asking Ollama. @@ -184,10 +223,12 @@ pub async fn generate_title( ChatMessage { role: "system".to_string(), content: system_prompt.0.clone(), + images: None, }, ChatMessage { role: "user".to_string(), content: title_prompt, + images: None, }, ]; @@ -246,11 +287,13 @@ mod tests { role: "user".to_string(), content: "What is Rust?".to_string(), quoted_text: None, + image_paths: Some(vec!["/tmp/img.jpg".to_string()]), }, SaveMessagePayload { role: "assistant".to_string(), content: "Rust is a systems programming language.".to_string(), quoted_text: None, + image_paths: None, }, ]; @@ -261,12 +304,17 @@ mod tests { .map(|m| m.content.trim().to_string()); let conversation_id = - database::create_conversation(&conn, placeholder_title.as_deref(), "llama3.2:3b") + database::create_conversation(&conn, placeholder_title.as_deref(), "gemma3:4b") .unwrap(); - let batch: Vec<(String, String, Option)> = messages + let batch: Vec<(String, String, Option, Option)> = messages .into_iter() - .map(|m| (m.role, m.content, m.quoted_text)) + .map(|m| { + let image_json = m.image_paths.filter(|v| !v.is_empty()).map(|v| { + serde_json::to_string(&v).expect("Vec serialization is infallible") + }); + (m.role, m.content, m.quoted_text, image_json) + }) .collect(); database::insert_messages_batch(&conn, &conversation_id, &batch).unwrap(); @@ -276,7 +324,12 @@ mod tests { assert_eq!(loaded.len(), 2); assert_eq!(loaded[0].role, "user"); assert_eq!(loaded[0].content, "What is Rust?"); + assert_eq!( + loaded[0].image_paths.as_deref(), + Some(r#"["/tmp/img.jpg"]"#) + ); assert_eq!(loaded[1].role, "assistant"); + assert!(loaded[1].image_paths.is_none()); } #[test] diff --git a/src-tauri/src/images.rs b/src-tauri/src/images.rs new file mode 100644 index 00000000..684b486b --- /dev/null +++ b/src-tauri/src/images.rs @@ -0,0 +1,483 @@ +/*! + * Image storage and lifecycle management. + * + * Images are stored in a flat directory at `/images/` with + * UUID-based filenames. This follows the industry-standard pattern used by + * Signal, iMessage, and Slack — media files are independent entities linked + * to messages through path references, not organized by conversation. + * + * Each image is compressed to JPEG (quality 85, max 1920px) on save to keep + * disk usage and Ollama inference latency low. + * + * Lifecycle: + * - **Paste/drop:** frontend sends raw bytes → `save_image` compresses and + * writes to the flat images directory, returns the file path. + * - **Remove:** user clicks "X" on a thumbnail → `remove_image` deletes the + * file from disk. + * - **Cleanup:** `cleanup_orphaned_images` removes files not referenced by + * any saved message. Runs on startup and periodically. + */ + +use std::path::{Path, PathBuf}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use image::imageops::FilterType; +use tauri::Manager; + +/// Maximum dimension (width or height) for saved images. Images exceeding this +/// are downscaled proportionally, preserving aspect ratio. +const MAX_DIMENSION: u32 = 1920; + +/// JPEG compression quality (1–100). 85 balances file size and visual fidelity +/// for vision model consumption. +const JPEG_QUALITY: u8 = 85; + +/// Maximum number of images allowed per message. +pub const MAX_IMAGES_PER_MESSAGE: usize = 3; + +/// Resolves the root images directory: `/images/`. +pub fn images_root(base_dir: &Path) -> PathBuf { + base_dir.join("images") +} + +/// Returns a closure that formats an error with a contextual message prefix. +/// One shared generic instantiation instead of N separate closure functions +/// in the llvm-cov function table. +fn err(context: &'static str) -> impl FnOnce(E) -> String { + move |e| format!("{context}: {e}") +} + +/// Compresses raw image bytes to JPEG (max 1920px) and writes to the flat +/// images directory with a UUID filename. +/// +/// Returns the absolute path of the saved file. The caller owns the path and +/// can pass it to the frontend for `asset://` rendering. +/// +/// # Errors +/// +/// Returns an error if the image bytes cannot be decoded, the output directory +/// cannot be created, or the file cannot be written. +pub fn save_image(base_dir: &Path, image_data: &[u8]) -> Result { + let img = image::load_from_memory(image_data).map_err(err("failed to decode image"))?; + + let resized = if img.width() > MAX_DIMENSION || img.height() > MAX_DIMENSION { + img.resize(MAX_DIMENSION, MAX_DIMENSION, FilterType::Lanczos3) + } else { + img + }; + + let dir = images_root(base_dir); + std::fs::create_dir_all(&dir).map_err(err("failed to create image directory"))?; + + let filename = format!("{}.jpg", uuid::Uuid::new_v4()); + let path = dir.join(&filename); + + let rgb = resized.to_rgb8(); + let mut jpeg_buf = Vec::new(); + { + let mut encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_buf, JPEG_QUALITY); + encoder + .encode_image(&rgb) + .map_err(err("failed to encode JPEG"))?; + } + + std::fs::write(&path, &jpeg_buf).map_err(err("failed to write image file"))?; + + path.to_str() + .map(|s| s.to_string()) + .ok_or("image path contains non-UTF-8 characters".to_string()) +} + +/// Deletes a single image file from disk, provided it resides within the +/// given `base_dir/images/` directory. Rejects paths outside the images root +/// to prevent path-traversal attacks via the IPC boundary. +/// +/// # Errors +/// +/// Returns an error if the path escapes the images directory or the file +/// cannot be removed. Silently succeeds if the file does not exist (idempotent). +pub fn remove_image(base_dir: &Path, path: &str) -> Result<(), String> { + let p = Path::new(path); + if !p.exists() { + return Ok(()); + } + let canonical = p + .canonicalize() + .map_err(err("failed to resolve image path"))?; + let root = images_root(base_dir) + .canonicalize() + .map_err(err("failed to resolve images root"))?; + if !canonical.starts_with(&root) { + return Err("path is outside the images directory".to_string()); + } + std::fs::remove_file(p).map_err(err("failed to remove image"))?; + Ok(()) +} + +/// Removes image files that are not in the set of referenced paths. +/// +/// `referenced_paths` contains the absolute paths of all images currently +/// referenced by saved messages. Any file in `/images/` not in +/// this set is deleted. +/// +/// # Errors +/// +/// Returns an error if the images root directory cannot be read. +pub fn cleanup_orphaned_images( + base_dir: &Path, + referenced_paths: &[String], +) -> Result { + let root = images_root(base_dir); + if !root.exists() { + return Ok(0); + } + + let entries = std::fs::read_dir(&root).map_err(err("failed to read images directory"))?; + + let mut removed = 0; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let path_str = path.to_string_lossy().to_string(); + if !referenced_paths.contains(&path_str) && std::fs::remove_file(&path).is_ok() { + removed += 1; + } + } + + Ok(removed) +} + +/// Reads image files from disk and returns their base64-encoded contents +/// for inclusion in Ollama API requests. +/// +/// # Errors +/// +/// Returns an error if any file cannot be read. +pub fn encode_images_as_base64(paths: &[String]) -> Result, String> { + paths + .iter() + .map(|p| { + let bytes = std::fs::read(p).map_err(|e| format!("failed to read image {p}: {e}"))?; + Ok(BASE64.encode(&bytes)) + }) + .collect() +} + +// ─── Tauri commands ──────────────────────────────────────────────────────── +// +// Thin wrappers that delegate to the pure functions above. Excluded from +// coverage builds entirely (`#[cfg(not(coverage))]`) because `coverage(off)` +// suppresses instrumentation but llvm-cov still counts excluded function +// signatures as "missed lines" in the summary — breaking the 100% gate. + +/// Compresses and saves an image to the flat images directory. +/// +/// Accepts base64-encoded image data as a string to avoid the performance +/// penalty of JSON-serializing a `Vec` (millions of individual numbers) +/// over the Tauri IPC bridge. +/// +/// The command is `async` so Tauri runs it off the main thread. The heavy +/// work (base64 decode → image decode → Lanczos3 resize → JPEG encode) is +/// dispatched to `spawn_blocking` to avoid blocking the async runtime, +/// keeping the WebView UI fully responsive during processing. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub async fn save_image_command( + app_handle: tauri::AppHandle, + image_data_base64: String, +) -> Result { + let base_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + + tokio::task::spawn_blocking(move || { + let image_data = BASE64 + .decode(&image_data_base64) + .map_err(|e| format!("failed to decode base64: {e}"))?; + save_image(&base_dir, &image_data) + }) + .await + .map_err(|e| format!("image processing task failed: {e}"))? +} + +/// Deletes a single image file from disk (with path containment check). +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub fn remove_image_command(app_handle: tauri::AppHandle, path: String) -> Result<(), String> { + let base_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + remove_image(&base_dir, &path) +} + +/// Removes image files not referenced by any saved message. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub fn cleanup_orphaned_images_command( + app_handle: tauri::AppHandle, + referenced_paths: Vec, +) -> Result { + let base_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + cleanup_orphaned_images(&base_dir, &referenced_paths) +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// Creates a minimal valid 1x1 red PNG for testing. + fn tiny_png() -> Vec { + let mut buf = Vec::new(); + let img = image::RgbImage::from_pixel(1, 1, image::Rgb([255, 0, 0])); + let dyn_img = image::DynamicImage::ImageRgb8(img); + let mut cursor = std::io::Cursor::new(&mut buf); + dyn_img + .write_to(&mut cursor, image::ImageFormat::Png) + .unwrap(); + buf + } + + /// Creates a large PNG (2000x1500) that exceeds MAX_DIMENSION. + fn large_png() -> Vec { + let mut buf = Vec::new(); + let img = image::RgbImage::from_pixel(2000, 1500, image::Rgb([0, 128, 255])); + let dyn_img = image::DynamicImage::ImageRgb8(img); + let mut cursor = std::io::Cursor::new(&mut buf); + dyn_img + .write_to(&mut cursor, image::ImageFormat::Png) + .unwrap(); + buf + } + + fn temp_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("thuki-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn save_image_creates_jpeg_file() { + let base = temp_dir(); + let path = save_image(&base, &tiny_png()).unwrap(); + + assert!(Path::new(&path).exists()); + assert!(path.ends_with(".jpg")); + // File should be in the flat images/ directory, not a subdirectory. + assert!(path.contains("/images/")); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn save_image_compresses_large_image() { + let base = temp_dir(); + let path = save_image(&base, &large_png()).unwrap(); + + let saved = image::open(&path).unwrap(); + assert!(saved.width() <= MAX_DIMENSION); + assert!(saved.height() <= MAX_DIMENSION); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn save_image_rejects_invalid_bytes() { + let base = temp_dir(); + let result = save_image(&base, b"not an image"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("failed to decode image")); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn save_image_preserves_aspect_ratio() { + let base = temp_dir(); + let mut buf = Vec::new(); + let img = image::RgbImage::from_pixel(3000, 1000, image::Rgb([0, 0, 0])); + let dyn_img = image::DynamicImage::ImageRgb8(img); + let mut cursor = std::io::Cursor::new(&mut buf); + dyn_img + .write_to(&mut cursor, image::ImageFormat::Png) + .unwrap(); + + let path = save_image(&base, &buf).unwrap(); + let saved = image::open(&path).unwrap(); + + assert_eq!(saved.width(), MAX_DIMENSION); + assert!(saved.height() < MAX_DIMENSION); + assert_eq!(saved.height(), 640); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn save_image_does_not_upscale_small_images() { + let base = temp_dir(); + let path = save_image(&base, &tiny_png()).unwrap(); + let saved = image::open(&path).unwrap(); + + assert_eq!(saved.width(), 1); + assert_eq!(saved.height(), 1); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn remove_image_deletes_file() { + let base = temp_dir(); + let path = save_image(&base, &tiny_png()).unwrap(); + assert!(Path::new(&path).exists()); + + remove_image(&base, &path).unwrap(); + assert!(!Path::new(&path).exists()); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn remove_image_idempotent_on_missing_file() { + let base = temp_dir(); + let result = remove_image(&base, "/tmp/nonexistent-thuki-image.jpg"); + assert!(result.is_ok()); + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn remove_image_rejects_path_outside_images_dir() { + let base = temp_dir(); + fs::create_dir_all(images_root(&base)).unwrap(); + let outside = base.join("secret.txt"); + fs::write(&outside, b"sensitive").unwrap(); + + let result = remove_image(&base, outside.to_str().unwrap()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("outside the images directory")); + // File must still exist — not deleted. + assert!(outside.exists()); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn cleanup_orphaned_images_removes_unreferenced_files() { + let base = temp_dir(); + let kept = save_image(&base, &tiny_png()).unwrap(); + let orphan = save_image(&base, &tiny_png()).unwrap(); + + let referenced = vec![kept.clone()]; + let removed = cleanup_orphaned_images(&base, &referenced).unwrap(); + + assert_eq!(removed, 1); + assert!(Path::new(&kept).exists()); + assert!(!Path::new(&orphan).exists()); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn cleanup_orphaned_images_noop_when_no_images_dir() { + let base = temp_dir(); + let removed = cleanup_orphaned_images(&base, &[]).unwrap(); + assert_eq!(removed, 0); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn cleanup_orphaned_images_removes_all_when_no_references() { + let base = temp_dir(); + save_image(&base, &tiny_png()).unwrap(); + save_image(&base, &tiny_png()).unwrap(); + + let removed = cleanup_orphaned_images(&base, &[]).unwrap(); + assert_eq!(removed, 2); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn cleanup_orphaned_images_preserves_all_when_all_referenced() { + let base = temp_dir(); + let p1 = save_image(&base, &tiny_png()).unwrap(); + let p2 = save_image(&base, &tiny_png()).unwrap(); + + let referenced = vec![p1, p2]; + let removed = cleanup_orphaned_images(&base, &referenced).unwrap(); + assert_eq!(removed, 0); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn cleanup_orphaned_images_skips_subdirectories() { + let base = temp_dir(); + save_image(&base, &tiny_png()).unwrap(); + // Create a subdirectory that should be skipped (not a file). + fs::create_dir_all(images_root(&base).join("stray-dir")).unwrap(); + + let removed = cleanup_orphaned_images(&base, &[]).unwrap(); + // Only the file should be removed, not the directory. + assert_eq!(removed, 1); + assert!(images_root(&base).join("stray-dir").exists()); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn encode_images_as_base64_roundtrip() { + let base = temp_dir(); + let path = save_image(&base, &tiny_png()).unwrap(); + + let encoded = encode_images_as_base64(&[path.clone()]).unwrap(); + assert_eq!(encoded.len(), 1); + + let decoded = BASE64.decode(&encoded[0]).unwrap(); + assert!(!decoded.is_empty()); + // JPEG magic bytes: FF D8. + assert_eq!(decoded[0], 0xFF); + assert_eq!(decoded[1], 0xD8); + + fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn encode_images_as_base64_empty_list() { + let result = encode_images_as_base64(&[]).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn encode_images_as_base64_rejects_missing_file() { + let result = encode_images_as_base64(&["/tmp/nonexistent-thuki.jpg".to_string()]); + assert!(result.is_err()); + } + + #[test] + fn images_root_resolves_correctly() { + let base = Path::new("/tmp/thuki-test"); + assert_eq!(images_root(base), PathBuf::from("/tmp/thuki-test/images")); + } + + #[test] + fn max_images_per_message_is_three() { + assert_eq!(MAX_IMAGES_PER_MESSAGE, 3); + } + + #[test] + fn err_helper_formats_context_and_cause() { + let format_fn = err("failed to frobnicate"); + assert_eq!(format_fn("disk full"), "failed to frobnicate: disk full"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71131de9..b05d2d48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ pub mod commands; pub mod database; pub mod history; +pub mod images; #[cfg(target_os = "macos")] mod activator; @@ -394,6 +395,46 @@ fn init_panel(app_handle: &tauri::AppHandle) { panel.set_has_shadow(false); } +// ─── Image cleanup ────────────────────────────────────────────────────────── + +/// Interval between periodic orphaned-image cleanup sweeps. +const IMAGE_CLEANUP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600); + +/// Runs a single orphaned-image cleanup sweep. Thin orchestration wrapper +/// that delegates to `database::get_all_image_paths` and +/// `images::cleanup_orphaned_images`, both independently tested. +#[cfg_attr(coverage_nightly, coverage(off))] +fn run_image_cleanup(app_handle: &tauri::AppHandle) { + let db = app_handle.state::(); + let conn = match db.0.lock() { + Ok(c) => c, + Err(_) => return, + }; + let referenced = database::get_all_image_paths(&conn).unwrap_or_default(); + drop(conn); + + let base_dir = match app_handle.path().app_data_dir() { + Ok(d) => d, + Err(_) => return, + }; + let _ = images::cleanup_orphaned_images(&base_dir, &referenced); +} + +/// Spawns a background Tokio task that runs the cleanup sweep on a fixed +/// interval. Thin async wrapper — delegates to `run_image_cleanup`. +#[cfg_attr(coverage_nightly, coverage(off))] +fn spawn_periodic_image_cleanup(app_handle: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(IMAGE_CLEANUP_INTERVAL); + // Skip the first tick (startup cleanup already ran synchronously). + interval.tick().await; + loop { + interval.tick().await; + run_image_cleanup(&app_handle); + } + }); +} + // ─── Application entry point ───────────────────────────────────────────────── /// Initialises and runs the Tauri application. @@ -493,10 +534,18 @@ pub fn run() { app.manage(commands::SystemPrompt(commands::load_system_prompt())); // ── SQLite database for conversation history ────────── - let db_conn = database::open_database() - .expect("failed to initialise SQLite database at ~/.thuki/thuki.db"); + let app_data_dir = app + .path() + .app_data_dir() + .expect("failed to resolve app data directory"); + let db_conn = database::open_database(&app_data_dir) + .expect("failed to initialise SQLite database"); app.manage(history::Database(std::sync::Mutex::new(db_conn))); + // ── Orphaned image cleanup (startup + periodic) ───────── + run_image_cleanup(app.handle()); + spawn_periodic_image_cleanup(app.handle().clone()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -518,6 +567,12 @@ pub fn run() { history::delete_conversation, #[cfg(not(coverage))] history::generate_title, + #[cfg(not(coverage))] + images::save_image_command, + #[cfg(not(coverage))] + images::remove_image_command, + #[cfg(not(coverage))] + images::cleanup_orphaned_images_command, notify_overlay_hidden, set_window_frame ]) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 569b7595..b22e5556 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,7 +25,11 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';", + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost;", + "assetProtocol": { + "enable": true, + "scope": ["$APPDATA/images/**"] + }, "capabilities": ["default"] }, "macOSPrivateApi": true diff --git a/src/App.tsx b/src/App.tsx index bc234268..81db8760 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,19 +2,22 @@ import { motion, AnimatePresence } from 'framer-motion'; import type React from 'react'; import { useState, useEffect, useCallback, useRef } from 'react'; import { listen } from '@tauri-apps/api/event'; -import { invoke } from '@tauri-apps/api/core'; +import { invoke, convertFileSrc } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { LogicalSize } from '@tauri-apps/api/dpi'; import { useOllama } from './hooks/useOllama'; +import type { Message } from './hooks/useOllama'; import { useConversationHistory } from './hooks/useConversationHistory'; import { ConversationView } from './view/ConversationView'; import { AskBarView } from './view/AskBarView'; import { HistoryPanel } from './components/HistoryPanel'; +import { ImagePreviewModal } from './components/ImagePreviewModal'; +import type { AttachedImage } from './types/image'; import { quote } from './config'; import './App.css'; /** Ollama model used for this session — must match the Rust DEFAULT_MODEL_NAME. */ -const MODEL_NAME = 'llama3.2:3b'; +const MODEL_NAME = 'gemma3:4b'; const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; @@ -116,6 +119,28 @@ function App() { const inputRef = useRef(null); + /** Images attached to the current (unsent) message. Blob URLs render + * immediately; file paths are set asynchronously after Rust processing. */ + const [attachedImages, setAttachedImages] = useState([]); + /** URL of the image currently open in the preview modal (blob or asset URL). */ + const [previewImageUrl, setPreviewImageUrl] = useState(null); + + /** When the user submits while images are still processing, the submit + * intent is stored here. The effect below watches `attachedImages` and + * fires the actual `ask()` once every image has a resolved `filePath`. */ + const pendingSubmitRef = useRef<{ + query: string; + context: string | undefined; + } | null>(null); + /** True while waiting for images to finish processing before a deferred + * submit. Drives the "waiting" UI state in the ask bar. */ + const [isSubmitPending, setIsSubmitPending] = useState(false); + /** User message shown in the chat while waiting for images to finish + * processing. Cleared when `ask()` fires and adds the real message. */ + const [pendingUserMessage, setPendingUserMessage] = useState( + null, + ); + /** * Session counter — incremented on each overlay open. Used in the motion * key to force AnimatePresence to fully unmount the stale tree before @@ -136,7 +161,7 @@ function App() { * chat window state with message bubbles. Transitions from input-bar mode * to chat-window mode are animated via Framer Motion `layout` prop. */ - const isChatMode = messages.length > 0 || isGenerating; + const isChatMode = messages.length > 0 || isGenerating || isSubmitPending; /** * The bookmark save button is active once the AI has produced at least one @@ -310,6 +335,14 @@ function App() { setQuery(''); setSelectedContext(context); setIsHistoryOpen(false); + setAttachedImages((prev) => { + for (const img of prev) URL.revokeObjectURL(img.blobUrl); + return []; + }); + pendingSubmitRef.current = null; + setIsSubmitPending(false); + setPendingUserMessage(null); + reset(); resetHistory(); setOverlayState('visible'); @@ -330,6 +363,11 @@ function App() { } /* v8 ignore stop */ setSelectedContext(null); + setPreviewImageUrl(null); + setAttachedImages((prev) => { + for (const img of prev) URL.revokeObjectURL(img.blobUrl); + return []; + }); setOverlayState((currentState) => { if (currentState === 'hidden' || currentState === 'hiding') { return currentState; @@ -370,9 +408,13 @@ function App() { }, [isChatMode, isHistoryOpen]); // Clear any pending new-conversation confirmation whenever the panel closes. - useEffect(() => { - if (!isHistoryOpen) setPendingNewConversation(false); - }, [isHistoryOpen]); + // Uses a ref-based approach to avoid the @eslint-react/set-state-in-effect + // warning from calling setState synchronously inside an effect body. + const prevHistoryOpenRef = useRef(isHistoryOpen); + if (prevHistoryOpenRef.current && !isHistoryOpen) { + setPendingNewConversation(false); + } + prevHistoryOpenRef.current = isHistoryOpen; /** * Observes the dropdown's height while it's open and mutates the morphing @@ -518,6 +560,13 @@ function App() { resetHistory(); setIsHistoryOpen(false); setQuery(''); + setAttachedImages((prev) => { + for (const img of prev) URL.revokeObjectURL(img.blobUrl); + return []; + }); + pendingSubmitRef.current = null; + setIsSubmitPending(false); + setPendingUserMessage(null); }, [reset, resetHistory]); /** @@ -550,8 +599,109 @@ function App() { resetForNewConversation(); }, [resetForNewConversation]); + /** + * Handles newly attached image files. Creates blob URLs immediately for + * instant thumbnail rendering, then processes each file in the background + * via base64-encoded IPC to the Rust backend. + */ + const handleImagesAttached = useCallback((files: File[]) => { + const newImages: AttachedImage[] = files.map((file) => ({ + id: crypto.randomUUID(), + blobUrl: URL.createObjectURL(file), + filePath: null, + })); + + setAttachedImages((prev) => [...prev, ...newImages]); + + // Defer backend processing to the next frame so React can render the + // blob URL thumbnails immediately — keeps the UI responsive while + // FileReader + IPC serialisation happen in subsequent event-loop ticks. + requestAnimationFrame(() => { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const imageId = newImages[i].id; + + const reader = new FileReader(); + reader.onload = () => { + // Extract pure base64 from the data URL (strip "data:image/png;base64,"). + const base64 = (reader.result as string).split(',')[1]; + invoke('save_image_command', { imageDataBase64: base64 }) + .then((filePath) => { + setAttachedImages((prev) => + prev.map((img) => + img.id === imageId ? { ...img, filePath } : img, + ), + ); + }) + .catch(() => { + setAttachedImages((prev) => { + for (const img of prev) { + if (img.id === imageId) URL.revokeObjectURL(img.blobUrl); + } + return prev.filter((img) => img.id !== imageId); + }); + }); + }; + reader.readAsDataURL(file); + } + }); + }, []); + + /** Removes an attached image from state, revokes the blob URL, and + * deletes the staged file from disk if processing completed. */ + const handleImageRemove = useCallback((id: string) => { + setAttachedImages((prev) => { + const img = prev.find((i) => i.id === id); + if (img) { + URL.revokeObjectURL(img.blobUrl); + if (img.filePath) { + void invoke('remove_image_command', { path: img.filePath }); + } + } + return prev.filter((i) => i.id !== id); + }); + }, []); + + /** Opens the preview modal for an attached image (identified by ID). + * The ID always comes from the thumbnail component which only renders + * items present in attachedImages, so the find always succeeds. */ + const handleAskBarImagePreview = useCallback( + (id: string) => { + setPreviewImageUrl(attachedImages.find((i) => i.id === id)!.blobUrl); + }, + [attachedImages], + ); + + /** Opens the preview modal for a chat history image (identified by file path). */ + const handleChatImagePreview = useCallback((path: string) => { + setPreviewImageUrl(path.startsWith('blob:') ? path : convertFileSrc(path)); + }, []); + + /** Fires the actual ask() call and cleans up attached images + input. */ + const executeSubmit = useCallback( + (submitQuery: string, context: string | undefined) => { + const readyPaths = attachedImages + .filter((img) => img.filePath !== null) + .map((img) => img.filePath as string); + const images = readyPaths.length > 0 ? readyPaths : undefined; + ask(submitQuery, context, images); + setSelectedContext(null); + setQuery(''); + for (const img of attachedImages) { + URL.revokeObjectURL(img.blobUrl); + } + setAttachedImages([]); + inputRef.current!.style.height = 'auto'; + }, + [ask, attachedImages, setSelectedContext], + ); + const handleSubmit = useCallback(() => { - if (query.trim().length === 0 || isGenerating) return; + if ( + (query.trim().length === 0 && attachedImages.length === 0) || + isGenerating + ) + return; // Sanitize externally-sourced context: strip control characters and enforce // a length cap to limit prompt-injection surface from host-app selections. // eslint-disable-next-line no-control-regex @@ -559,14 +709,101 @@ function App() { const sanitized = selectedContext ?.replace(CONTROL_CHARS, '') .slice(0, quote.maxContextLength); - const hasContext = sanitized && sanitized.trim().length > 0; - ask(query, hasContext ? sanitized : undefined); - setSelectedContext(null); + const context = sanitized?.trim() ? sanitized : undefined; + + // If all images are ready (or there are none), submit immediately. + const hasPendingImages = attachedImages.some( + (img) => img.filePath === null, + ); + if (!hasPendingImages) { + executeSubmit(query, context); + return; + } + + // Images are still processing — store the intent and wait. The effect + // below will fire the actual ask() once every image has resolved. + pendingSubmitRef.current = { query, context }; + setIsSubmitPending(true); + + // Show the user's message immediately in the chat view. Use file paths + // for already-processed images (no loading spinner) and blob URLs only + // for images still being processed (ChatBubble shows a spinner for blob: URLs). + setPendingUserMessage({ + id: crypto.randomUUID(), + role: 'user', + content: query, + quotedText: context, + imagePaths: attachedImages.map((img) => img.filePath ?? img.blobUrl), + }); + setQuery(''); - if (inputRef.current) { - inputRef.current.style.height = 'auto'; + setSelectedContext(null); + inputRef.current!.style.height = 'auto'; + }, [ + query, + isGenerating, + executeSubmit, + selectedContext, + setSelectedContext, + attachedImages, + ]); + + // When a pending submit exists and all images finish processing, fire it. + // Reads `attachedImages` directly (not via `executeSubmit` closure) to + // guarantee the effect always sees the freshest file paths. + /* eslint-disable @eslint-react/set-state-in-effect -- intentional: effect + reacts to image processing completion and must synchronously transition + state (pending → submitted) in the same tick to avoid stale renders. */ + useEffect(() => { + if (!pendingSubmitRef.current) return; + if (attachedImages.length === 0) { + // All images failed — restore the user's query so their text isn't lost. + const { query: savedQuery, context: savedContext } = + pendingSubmitRef.current; + pendingSubmitRef.current = null; + setIsSubmitPending(false); + setPendingUserMessage(null); + setQuery(savedQuery); + setSelectedContext(savedContext ?? null); + return; + } + // Wait until every image has finished backend processing. + const allReady = attachedImages.every((img) => img.filePath !== null); + if (!allReady) return; + + const { query: pendingQuery, context } = pendingSubmitRef.current; + pendingSubmitRef.current = null; + setIsSubmitPending(false); + // Clear the preview message — ask() will add the real one with file paths. + setPendingUserMessage(null); + + const images = attachedImages.map((img) => img.filePath as string); + void ask(pendingQuery, context, images); + setSelectedContext(null); + for (const img of attachedImages) { + URL.revokeObjectURL(img.blobUrl); + } + setAttachedImages([]); + }, [attachedImages, ask, setSelectedContext]); + /* eslint-enable @eslint-react/set-state-in-effect */ + + /** Unified cancel handler: reverts a pending submit (undo-send) or cancels + * an active Ollama generation. When reverting, restores the user's query + * and keeps attached images so they can re-submit or edit. */ + const handleCancel = useCallback(() => { + if (isSubmitPending && pendingSubmitRef.current) { + // Undo send — restore input state to before the user hit Enter. + setQuery(pendingSubmitRef.current.query); + setSelectedContext(pendingSubmitRef.current.context ?? null); + pendingSubmitRef.current = null; + setIsSubmitPending(false); + setPendingUserMessage(null); + // Re-focus the textarea so the user can immediately edit. + requestAnimationFrame(() => inputRef.current?.focus()); + return; } - }, [query, isGenerating, ask, selectedContext, setSelectedContext]); + cancel(); + }, [isSubmitPending, cancel, setSelectedContext]); /** * Synchronizes the React animation state with Tauri-driven overlay visibility @@ -742,9 +979,13 @@ function App() { {isChatMode ? ( ) : null} @@ -800,11 +1042,16 @@ function App() { setQuery={setQuery} isChatMode={isChatMode} isGenerating={isGenerating} + isSubmitPending={isSubmitPending} onSubmit={handleSubmit} - onCancel={cancel} + onCancel={handleCancel} inputRef={inputRef} selectedText={selectedContext ?? undefined} onHistoryOpen={handleHistoryToggle} + attachedImages={isSubmitPending ? [] : attachedImages} + onImagesAttached={handleImagesAttached} + onImageRemove={handleImageRemove} + onImagePreview={handleAskBarImagePreview} /> @@ -844,6 +1091,10 @@ function App() { ) : null} + setPreviewImageUrl(null)} + /> ); } diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index ea11251f..d386200f 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1038,6 +1038,68 @@ describe('App', () => { ).toBeInTheDocument(); }); + it('handleNewConversation revokes blob URLs when images are attached', async () => { + enableChannelCaptureWithResponses({ + list_conversations: [], + save_image_command: '/tmp/img.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Get into chat mode with an unsaved turn + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'question' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'answer' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // Paste an image while in chat mode (unsaved conversation) + const replyInput = screen.getByPlaceholderText('Reply...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(replyInput, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + const revokeSpy = vi.mocked(URL.revokeObjectURL); + revokeSpy.mockClear(); + + // Click + → SwitchConfirmation (unsaved conversation) + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'New conversation' }), + ); + }); + + // Click "Start New" → resetForNewConversation revokes blob URLs + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Start New' })); + }); + + expect(revokeSpy).toHaveBeenCalled(); + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + it('handleNewConversation saves then resets on Save & Start New', async () => { enableChannelCaptureWithResponses({ list_conversations: [], @@ -1151,7 +1213,7 @@ describe('App', () => { { id: 'conv-other2', title: 'Other chat', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: 1, message_count: 2, }, @@ -1208,7 +1270,7 @@ describe('App', () => { { id: 'c2', title: 'Other chat', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: 1, message_count: 1, }, @@ -1279,7 +1341,7 @@ describe('App', () => { { id: 'conv-other', title: 'Switch target', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: 1, message_count: 2, }, @@ -1356,7 +1418,7 @@ describe('App', () => { { id: 'conv-target', title: 'My chat', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: 1, message_count: 2, }, @@ -1500,7 +1562,7 @@ describe('App', () => { { id: 'conv-active', title: 'Active chat', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: 1, message_count: 2, }, @@ -1562,7 +1624,7 @@ describe('App', () => { { id: 'c1', title: 'Chat', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: 1, message_count: 1, }, @@ -1596,7 +1658,7 @@ describe('App', () => { { id: 'conv-unrelated', title: 'Unrelated', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: 1, message_count: 2, }, @@ -1625,6 +1687,926 @@ describe('App', () => { }); }); + // ─── Image integration ───────────────────────────────────────────────────── + + describe('image integration', () => { + /** Helper: paste an image file into the textarea and wait for thumbnails. */ + async function pasteImage() { + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['fake-img-data'], 'photo.png', { + type: 'image/png', + }); + const clipboardData = { + items: [{ type: 'image/png', getAsFile: () => file }], + }; + await act(async () => { + fireEvent.paste(textarea, { clipboardData }); + }); + // Thumbnails appear immediately via blob URL (before backend completes) + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + } + + it('handleImagesAttached stages images and shows thumbnails', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/staged/img1.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await pasteImage(); + + // Wait for FileReader + invoke to complete in background + await act(async () => { + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.objectContaining({ + imageDataBase64: expect.any(String), + }), + ); + }); + }); + + // Thumbnails should still be present + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + it('handleImageRemove removes thumbnail and calls remove_image_command', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/staged/img1.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await pasteImage(); + + // Wait for backend to resolve (filePath set) + await act(async () => { + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + }); + + invoke.mockClear(); + + // Click remove button on the thumbnail + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /remove/i })); + }); + + expect(invoke).toHaveBeenCalledWith('remove_image_command', { + path: '/tmp/staged/img1.jpg', + }); + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + + it('handleSubmit with images passes imagePaths and clears attachedImages', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/staged/img1.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await pasteImage(); + + // Wait for backend to resolve (filePath set) + await act(async () => { + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + }); + + // Type a message and submit + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'describe this' } }); + }); + + invoke.mockClear(); + enableChannelCapture(); + + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + // ask_ollama should be called with imagePaths + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'describe this', + imagePaths: ['/tmp/staged/img1.jpg'], + }), + ); + }); + + it('submits with images and no text', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/staged/img1.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await pasteImage(); + + // Wait for backend to resolve + await act(async () => { + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + }); + + invoke.mockClear(); + enableChannelCapture(); + + // Submit with Enter (no text, just images) + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + // ask_ollama should be called with empty message but imagePaths + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: '', + imagePaths: ['/tmp/staged/img1.jpg'], + }), + ); + }); + + it('previewImage opens ImagePreviewModal and closing clears it', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/staged/img1.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await pasteImage(); + + // Click preview button on thumbnail + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /preview/i })); + }); + + // ImagePreviewModal should be open (has role="dialog") + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Close the modal + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /close preview/i })); + }); + + // Dialog should be gone + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('handleImagesAttached removes image when backend fails', async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'save_image_command') throw new Error('disk full'); + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.drop( + document.querySelector('[class*="flex flex-col w-full shrink-0"]')!, + { + preventDefault: vi.fn(), + dataTransfer: { files: [file] }, + }, + ); + }); + + // Thumbnail appears immediately via blob URL + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + // Wait for FileReader + invoke to settle — failed image gets removed + await act(async () => { + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + }); + + // Image should be removed after backend failure + await vi.waitFor(() => { + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + }); + + it('handleImagesAttached skips images that fail to stage', async () => { + // First call succeeds, second call fails + let saveCallCount = 0; + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + // channel capture — no-op for this test + } + if (cmd === 'save_image_command') { + saveCallCount++; + if (saveCallCount === 2) throw new Error('disk full'); + return '/tmp/staged/img1.jpg'; + } + }, + ); + + render(); + await act(async () => {}); + await showOverlay(); + + // Drop two image files via the AskBarView wrapper + const askBarWrapper = document.querySelector( + '[class*="flex flex-col w-full shrink-0"]', + ); + expect(askBarWrapper).not.toBeNull(); + + const file1 = new File(['data1'], 'img1.png', { type: 'image/png' }); + const file2 = new File(['data2'], 'img2.png', { type: 'image/png' }); + fireEvent.drop(askBarWrapper!, { + preventDefault: vi.fn(), + dataTransfer: { files: [file1, file2] }, + }); + + // Both thumbnails appear immediately + await vi.waitFor(() => { + expect(screen.getAllByRole('listitem')).toHaveLength(2); + }); + + // Wait for both backend calls to settle + await act(async () => { + await vi.waitFor(() => { + expect(saveCallCount).toBe(2); + }); + }); + + // Failed image gets removed, only one remains + await vi.waitFor(() => { + expect(screen.getAllByRole('listitem')).toHaveLength(1); + }); + }); + + it('handleChatImagePreview opens modal for chat history image', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/staged/img1.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await pasteImage(); + + // Wait for backend to resolve + await act(async () => { + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + }); + + // Type and submit to create a user message with image + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'what is this?' } }); + }); + + invoke.mockClear(); + enableChannelCapture(); + + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + // Simulate AI response completing + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'It is' }); + getLastChannel()?.simulateMessage({ type: 'Token', data: ' a cat.' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + + // The user message should have a thumbnail from chat history (via convertFileSrc) + // Find the preview button in the chat bubble (not the ask bar) + const previewButtons = screen.getAllByRole('button', { + name: /preview/i, + }); + // The chat bubble thumbnail should be present + expect(previewButtons.length).toBeGreaterThan(0); + + await act(async () => { + fireEvent.click(previewButtons[0]); + }); + + // ImagePreviewModal should be open + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Close it + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /close preview/i })); + }); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('handleChatImagePreview passes blob URLs through without convertFileSrc', async () => { + // Make save_image_command hang so the image stays as a blob URL + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + // channel capture + } + if (cmd === 'save_image_command') { + return new Promise(() => {}); // never resolves + } + }, + ); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste and submit while still processing → pendingUserMessage with blob URL + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(textarea, { target: { value: 'what is this?' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + // Pending user message should be visible in chat with a blob URL thumbnail + await vi.waitFor(() => { + expect(screen.getByText('what is this?')).toBeInTheDocument(); + }); + + // Click the preview button in the chat bubble — should open the modal + // with the blob URL directly (no convertFileSrc wrapping). + const previewButtons = screen.getAllByRole('button', { + name: /preview/i, + }); + expect(previewButtons.length).toBeGreaterThan(0); + + await act(async () => { + fireEvent.click(previewButtons[0]); + }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Flush stale FileReader macrotask so it doesn't leak into the next test. + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + }); + + it('handleImageRemove is safe when called twice for the same image', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/staged/img1.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + await pasteImage(); + + // Wait for backend to resolve + await act(async () => { + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + }); + + invoke.mockClear(); + + // Click remove twice rapidly — the second call should be a no-op + // (the functional updater in setAttachedImages will find no matching + // image on the second pass, exercising the !img branch). + const removeBtn = screen.getByRole('button', { name: /remove/i }); + await act(async () => { + fireEvent.click(removeBtn); + fireEvent.click(removeBtn); + }); + + // remove_image_command should only be called once + const removeCalls = invoke.mock.calls.filter( + (call) => call[0] === 'remove_image_command', + ); + expect(removeCalls).toHaveLength(1); + }); + + it('handleImageRemove revokes blob URL without calling remove_image_command when filePath is null', async () => { + // Make save_image_command hang forever (never resolve) + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + // channel capture — no-op + } + if (cmd === 'save_image_command') { + return new Promise(() => {}); // never resolves + } + }, + ); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste an image — thumbnail appears immediately with null filePath + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + invoke.mockClear(); + + // Remove the image while filePath is still null + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /remove/i })); + }); + + // Should NOT call remove_image_command (no file to delete) + expect(invoke).not.toHaveBeenCalledWith( + 'remove_image_command', + expect.anything(), + ); + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + + it('defers submit when images are still processing and fires when ready', async () => { + // Flush any stale macrotasks (e.g. FileReader.onload from prior tests) + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Track save_image_command calls scoped to THIS test + let resolveSave: ((path: string) => void) | null = null; + const savePromises: Promise[] = []; + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + // Accept channel for ask_ollama + } + if (cmd === 'save_image_command') { + const p = new Promise((resolve) => { + resolveSave = resolve; + }); + savePromises.push(p); + return p; + } + }, + ); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste an image — thumbnail appears immediately (filePath null) + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + // Wait for this test's FileReader to complete and call save_image_command + await act(async () => { + await vi.waitFor(() => expect(savePromises).toHaveLength(1)); + }); + + // Type and submit while image is still processing + act(() => { + fireEvent.change(textarea, { target: { value: 'describe this' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + // Should show "Processing images" state + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + + // Resolve the image — triggers deferred submit chain + resolveSave!('/tmp/staged/img1.jpg'); + + // Flush async chain: promise → state update → effect → ask → invoke + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // User message should appear in the chat (ask() fired the real submit) + expect(screen.getByText('describe this')).toBeInTheDocument(); + }); + + it('stop button cancels active generation via handleCancel', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/img.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Start a normal text conversation (no images) + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'hello' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + // Should be generating — stop button visible + const stopBtn = screen.getByRole('button', { name: /stop/i }); + expect(stopBtn).toBeInTheDocument(); + + // Click stop — should call cancel_generation + invoke.mockClear(); + enableChannelCapture(); + + await act(async () => { + fireEvent.click(stopBtn); + }); + + expect(invoke).toHaveBeenCalledWith('cancel_generation'); + }); + + it('cancelling during pending submit restores input (undo send)', async () => { + // Flush stale macrotasks from prior tests + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + // Accept channel + } + if (cmd === 'save_image_command') { + return new Promise(() => {}); // never resolves + } + }, + ); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste an image + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + // Type and submit while image is still processing + act(() => { + fireEvent.change(textarea, { target: { value: 'my question' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + // Should be in chat mode with stop button + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + + // Click stop to cancel the pending submit + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /stop/i })); + }); + + // Should revert to ask-bar mode with the query restored + const restoredTextarea = screen.getByPlaceholderText( + 'Ask Thuki anything...', + ); + expect(restoredTextarea).toBeInTheDocument(); + expect((restoredTextarea as HTMLTextAreaElement).value).toBe( + 'my question', + ); + + // Images should still be visible (still processing in background) + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + + // ask_ollama should never have been called + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); + }); + + it('waits for all images before firing deferred submit', async () => { + // Flush stale macrotasks from prior tests + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Two images: each gets its own resolve function + const resolvers: ((path: string) => void)[] = []; + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + // Accept channel + } + if (cmd === 'save_image_command') { + return new Promise((resolve) => { + resolvers.push(resolve); + }); + } + }, + ); + + render(); + await act(async () => {}); + await showOverlay(); + + // Drop two images at once + const askBarWrapper = document.querySelector( + '[class*="flex flex-col w-full shrink-0"]', + )!; + const file1 = new File(['d1'], 'a.png', { type: 'image/png' }); + const file2 = new File(['d2'], 'b.png', { type: 'image/png' }); + fireEvent.drop(askBarWrapper, { + preventDefault: vi.fn(), + dataTransfer: { files: [file1, file2] }, + }); + + // Wait for both save_image_command calls + await act(async () => { + await vi.waitFor(() => expect(resolvers).toHaveLength(2)); + }); + + // Submit while both images are still processing + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'two images' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + + // Resolve ONLY the first image — allReady should still be false + await act(async () => { + resolvers[0]('/tmp/img1.jpg'); + }); + await act(async () => {}); + + // Still processing — second image not ready + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + + // Resolve the second image — now allReady is true, submit fires + await act(async () => { + resolvers[1]('/tmp/img2.jpg'); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // User message should appear + expect(screen.getByText('two images')).toBeInTheDocument(); + }); + + it('cancels deferred submit when all images fail', async () => { + // Make save_image_command hang then reject + let rejectSave: ((err: Error) => void) | null = null; + invoke.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + // channel capture + } + if (cmd === 'save_image_command') { + return new Promise((_, reject) => { + rejectSave = reject; + }); + } + }, + ); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste and submit while processing + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(textarea, { target: { value: 'describe' } }); + }); + + // Wait for FileReader to complete and save_image_command to be invoked + // (which sets rejectSave via the promise constructor). + await act(async () => { + await vi.waitFor(() => { + expect(rejectSave).not.toBeNull(); + }); + }); + + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + // Waiting state + await vi.waitFor(() => { + expect( + screen.getByRole('button', { name: /stop/i }), + ).toBeInTheDocument(); + }); + + // Reject the image — it should be removed and pending submit cancelled + await act(async () => { + rejectSave!(new Error('disk full')); + }); + + // Image removed → no thumbnails → pending submit cancelled + await vi.waitFor(() => { + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + + // ask_ollama should never have been called + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); + + // The "Processing images" button should be gone — back to normal send + expect( + screen.getByRole('button', { name: /send message/i }), + ).toBeInTheDocument(); + + // User's query should be restored so their text isn't lost + expect(screen.getByPlaceholderText('Ask Thuki anything...')).toHaveValue( + 'describe', + ); + }); + }); + + it('revokes blob URLs when overlay reopens with attached images', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/img.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste an image so attachedImages is non-empty + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + // Reopen overlay — should clear images and revoke blob URLs + await showOverlay(); + + expect(URL.revokeObjectURL).toHaveBeenCalled(); + expect(screen.queryByRole('list', { name: /attached images/i })).toBeNull(); + }); + + it('revokes blob URLs when overlay hides with attached images', async () => { + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/img.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['data'], 'img.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + await vi.waitFor(() => { + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + }); + + const revokeSpy = vi.mocked(URL.revokeObjectURL); + revokeSpy.mockClear(); + + // Hide overlay via Escape — requestHideOverlay should revoke blob URLs + await act(async () => { + fireEvent.keyDown(window, { key: 'Escape' }); + }); + + expect(revokeSpy).toHaveBeenCalled(); + }); + it('resets session on overlay reopen', async () => { render(); await act(async () => {}); diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index 63726d5a..05b1f01d 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -1,6 +1,8 @@ import { motion } from 'framer-motion'; import { MarkdownRenderer } from './MarkdownRenderer'; import { CopyButton } from './CopyButton'; +import { ImageThumbnails } from './ImageThumbnails'; +import { convertFileSrc } from '@tauri-apps/api/core'; import { formatQuotedText } from '../utils/formatQuote'; import { quote } from '../config'; @@ -15,6 +17,10 @@ interface ChatBubbleProps { quotedText?: string; /** Whether this bubble is actively streaming content from the LLM. */ isStreaming?: boolean; + /** Absolute file paths of images attached to this message, if any. */ + imagePaths?: string[]; + /** Called when the user clicks a thumbnail to preview it. */ + onImagePreview?: (path: string) => void; } /** @@ -53,6 +59,8 @@ export function ChatBubble({ index, quotedText, isStreaming = false, + imagePaths, + onImagePreview, }: ChatBubbleProps) { const isUser = role === 'user'; @@ -77,11 +85,28 @@ export function ChatBubble({ )}

)} - {content} - -
- + {imagePaths && imagePaths.length > 0 && onImagePreview && ( +
+ ({ + id: p, + src: p.startsWith('blob:') ? p : convertFileSrc(p), + loading: p.startsWith('blob:'), + }))} + onPreview={onImagePreview} + size={48} + /> +
+ )} + {content && ( + {content} + )}
+ {content && ( +
+ +
+ )} ) : ( /* AI plain text — full width, no bubble chrome */ diff --git a/src/components/ImagePreviewModal.tsx b/src/components/ImagePreviewModal.tsx new file mode 100644 index 00000000..4c20ef55 --- /dev/null +++ b/src/components/ImagePreviewModal.tsx @@ -0,0 +1,88 @@ +import { motion, AnimatePresence } from 'framer-motion'; +import { useEffect, useCallback } from 'react'; + +interface ImagePreviewModalProps { + /** URL of the image to preview (blob URL or asset URL). Null when closed. */ + imageUrl: string | null; + /** Called when the modal should close. */ + onClose: () => void; +} + +/** + * Full-screen modal overlay that displays an image at its natural size, + * scaled to fit the viewport. Closes on backdrop click, close button, + * or Escape key. + */ +export function ImagePreviewModal({ + imageUrl, + onClose, +}: ImagePreviewModalProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }, + [onClose], + ); + + useEffect(() => { + if (!imageUrl) return; + window.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => + window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, [imageUrl, handleKeyDown]); + + return ( + + {imageUrl && ( + + e.stopPropagation()} + className="max-w-[90%] max-h-[90%] object-contain rounded-lg shadow-2xl" + /> + + + )} + + ); +} diff --git a/src/components/ImageThumbnails.tsx b/src/components/ImageThumbnails.tsx new file mode 100644 index 00000000..60532fea --- /dev/null +++ b/src/components/ImageThumbnails.tsx @@ -0,0 +1,103 @@ +import { motion, AnimatePresence } from 'framer-motion'; + +interface ThumbnailItem { + /** Unique key for React list rendering. */ + id: string; + /** URL to render (blob URL, asset URL, or any valid image src). */ + src: string; + /** Whether the image is still being processed by the backend. */ + loading?: boolean; +} + +interface ImageThumbnailsProps { + /** Images to display as thumbnails. */ + items: ThumbnailItem[]; + /** Called with the item ID when a thumbnail is clicked (opens preview). */ + onPreview: (id: string) => void; + /** Called with the item ID when the remove button is clicked. Omit to hide remove buttons. */ + onRemove?: (id: string) => void; + /** Thumbnail size in pixels. Defaults to 56. */ + size?: number; +} + +/** + * Renders a horizontal row of image thumbnails with optional remove buttons. + * Used in the ask bar (with remove) and in chat bubbles (without remove). + */ +export function ImageThumbnails({ + items, + onPreview, + onRemove, + size = 56, +}: ImageThumbnailsProps) { + if (items.length === 0) return null; + + return ( +
+ + {items.map((item) => ( + + + {onRemove && ( + + )} + + ))} + +
+ ); +} + +export type { ThumbnailItem }; diff --git a/src/components/__tests__/ChatBubble.test.tsx b/src/components/__tests__/ChatBubble.test.tsx index 64197fba..fb74225a 100644 --- a/src/components/__tests__/ChatBubble.test.tsx +++ b/src/components/__tests__/ChatBubble.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ChatBubble } from '../ChatBubble'; describe('ChatBubble', () => { @@ -132,6 +132,86 @@ describe('ChatBubble', () => { }); }); + describe('Image attachments', () => { + it('renders ImageThumbnails when imagePaths and onImagePreview are provided', () => { + render( + , + ); + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + }); + + it('does not render ImageThumbnails when imagePaths is not provided', () => { + render( + , + ); + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + + it('does not render ImageThumbnails when imagePaths is empty', () => { + render( + , + ); + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + + it('does not render ImageThumbnails when onImagePreview is not provided', () => { + render( + , + ); + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + + it('renders images but no text span when content is empty', () => { + const { container } = render( + , + ); + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + // The content span should not be rendered when content is empty + expect(container.querySelector('.text-white\\/95')).toBeNull(); + // CopyButton should also be hidden — nothing to copy + expect(screen.queryByRole('button', { name: /copy/i })).toBeNull(); + }); + }); + describe('Layout', () => { it('has max-width constraint (max-w-[80%])', () => { const { container } = render( diff --git a/src/components/__tests__/ConversationItem.test.tsx b/src/components/__tests__/ConversationItem.test.tsx index f20f6fdf..a29d488a 100644 --- a/src/components/__tests__/ConversationItem.test.tsx +++ b/src/components/__tests__/ConversationItem.test.tsx @@ -6,7 +6,7 @@ import type { ConversationSummary } from '../../types/history'; const SUMMARY: ConversationSummary = { id: 'conv-1', title: 'How does React work?', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: Date.now(), message_count: 6, }; diff --git a/src/components/__tests__/HistoryPanel.test.tsx b/src/components/__tests__/HistoryPanel.test.tsx index 08fefbd6..94936625 100644 --- a/src/components/__tests__/HistoryPanel.test.tsx +++ b/src/components/__tests__/HistoryPanel.test.tsx @@ -11,21 +11,21 @@ const CONVERSATIONS: ConversationSummary[] = [ { id: 'c1', title: 'React basics', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: NOW, message_count: 4, }, { id: 'c2', title: 'Python bug fix', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: YESTERDAY, message_count: 6, }, { id: 'c3', title: 'Old topic', - model: 'llama3.2:3b', + model: 'gemma3:4b', updated_at: OLDER, message_count: 2, }, diff --git a/src/components/__tests__/ImagePreviewModal.test.tsx b/src/components/__tests__/ImagePreviewModal.test.tsx new file mode 100644 index 00000000..c09d3f5b --- /dev/null +++ b/src/components/__tests__/ImagePreviewModal.test.tsx @@ -0,0 +1,141 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ImagePreviewModal } from '../ImagePreviewModal'; + +describe('ImagePreviewModal', () => { + describe('when imageUrl is null', () => { + it('renders nothing', () => { + const { container } = render( + , + ); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('does not register keydown listener when closed', () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('when imageUrl is set', () => { + const testUrl = 'blob:http://localhost/test-image-uuid'; + + it('renders a dialog with correct aria attributes', () => { + render(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-label', 'Image preview'); + }); + + it('renders the image with the provided URL directly', () => { + render(); + const img = screen.getByAltText('Preview'); + expect(img).toBeInTheDocument(); + expect(img.getAttribute('src')).toBe(testUrl); + }); + + it('renders the close button with aria-label', () => { + render(); + expect( + screen.getByRole('button', { name: 'Close preview' }), + ).toBeInTheDocument(); + }); + + it('renders the close icon SVG with aria-hidden', () => { + const { container } = render( + , + ); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg!.getAttribute('aria-hidden')).toBe('true'); + }); + }); + + describe('closing interactions', () => { + const testUrl = 'blob:http://localhost/test-image-uuid'; + + it('calls onClose when clicking the backdrop', () => { + const onClose = vi.fn(); + render(); + + const dialog = screen.getByRole('dialog'); + fireEvent.click(dialog); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when clicking the close button (also bubbles to backdrop)', () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Close preview' })); + // The button's onClick fires onClose, and the event bubbles to the + // backdrop's onClick which also fires onClose — 2 calls total. + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('calls onClose on Escape key press', () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose on non-Escape key press', () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(window, { key: 'Enter' }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('clicking the image does not call onClose (stopPropagation)', () => { + const onClose = vi.fn(); + render(); + + const img = screen.getByAltText('Preview'); + fireEvent.click(img); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('keydown listener lifecycle', () => { + it('removes keydown listener when imageUrl changes to null', () => { + const onClose = vi.fn(); + const { rerender } = render( + , + ); + + // Escape works while open + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + + // Close modal + rerender(); + + // Escape no longer triggers onClose + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('removes keydown listener on unmount', () => { + const onClose = vi.fn(); + const { unmount } = render( + , + ); + + unmount(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/__tests__/ImageThumbnails.test.tsx b/src/components/__tests__/ImageThumbnails.test.tsx new file mode 100644 index 00000000..6988a12e --- /dev/null +++ b/src/components/__tests__/ImageThumbnails.test.tsx @@ -0,0 +1,177 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ImageThumbnails } from '../ImageThumbnails'; +import type { ThumbnailItem } from '../ImageThumbnails'; + +describe('ImageThumbnails', () => { + const defaultItems: ThumbnailItem[] = [ + { id: 'img-1', src: 'blob:http://localhost/img1' }, + { id: 'img-2', src: 'blob:http://localhost/img2' }, + ]; + + it('returns null when items is empty', () => { + const { container } = render( + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders a list container with correct role and aria-label', () => { + render(); + const list = screen.getByRole('list', { name: 'Attached images' }); + expect(list).toBeInTheDocument(); + }); + + it('renders one listitem per item', () => { + render(); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(2); + }); + + it('renders images with the provided src directly', () => { + render(); + const images = screen.getAllByAltText('Attached'); + expect(images[0]).toHaveAttribute('src', 'blob:http://localhost/img1'); + expect(images[1]).toHaveAttribute('src', 'blob:http://localhost/img2'); + }); + + it('applies default size (56px) to images', () => { + render(); + const images = screen.getAllByAltText('Attached'); + expect(images[0]).toHaveStyle({ width: '56px', height: '56px' }); + }); + + it('applies custom size to images', () => { + render( + , + ); + const images = screen.getAllByAltText('Attached'); + expect(images[0]).toHaveStyle({ width: '80px', height: '80px' }); + expect(images[1]).toHaveStyle({ width: '80px', height: '80px' }); + }); + + it('calls onPreview with the correct id when thumbnail is clicked', () => { + const onPreview = vi.fn(); + render(); + const previewButtons = screen.getAllByRole('button', { + name: 'Preview image', + }); + fireEvent.click(previewButtons[0]); + expect(onPreview).toHaveBeenCalledWith('img-1'); + fireEvent.click(previewButtons[1]); + expect(onPreview).toHaveBeenCalledWith('img-2'); + expect(onPreview).toHaveBeenCalledTimes(2); + }); + + it('does not render remove buttons when onRemove is omitted', () => { + render(); + expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull(); + }); + + it('renders remove buttons when onRemove is provided', () => { + render( + , + ); + const removeButtons = screen.getAllByRole('button', { + name: 'Remove image', + }); + expect(removeButtons).toHaveLength(2); + }); + + it('calls onRemove with the correct id when remove button is clicked', () => { + const onRemove = vi.fn(); + render( + , + ); + const removeButtons = screen.getAllByRole('button', { + name: 'Remove image', + }); + fireEvent.click(removeButtons[0]); + expect(onRemove).toHaveBeenCalledWith('img-1'); + fireEvent.click(removeButtons[1]); + expect(onRemove).toHaveBeenCalledWith('img-2'); + expect(onRemove).toHaveBeenCalledTimes(2); + }); + + it('renders the close icon SVG inside remove buttons with aria-hidden', () => { + const { container } = render( + , + ); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('sets draggable=false on images', () => { + render( + , + ); + const img = screen.getByAltText('Attached'); + expect(img).toHaveAttribute('draggable', 'false'); + }); + + it('applies opacity class when item is loading', () => { + render( + , + ); + const img = screen.getByAltText('Attached'); + expect(img.classList.contains('opacity-50')).toBe(true); + }); + + it('does not apply opacity class when item is not loading', () => { + render( + , + ); + const img = screen.getByAltText('Attached'); + expect(img.classList.contains('opacity-50')).toBe(false); + }); + + it('renders spinner when item is loading', () => { + const { container } = render( + , + ); + expect(container.querySelector('.animate-spin')).not.toBeNull(); + }); + + it('does not render spinner when item is not loading', () => { + const { container } = render( + , + ); + expect(container.querySelector('.animate-spin')).toBeNull(); + }); +}); diff --git a/src/hooks/__tests__/useConversationHistory.test.tsx b/src/hooks/__tests__/useConversationHistory.test.tsx index 7d814584..c43c98de 100644 --- a/src/hooks/__tests__/useConversationHistory.test.tsx +++ b/src/hooks/__tests__/useConversationHistory.test.tsx @@ -9,7 +9,7 @@ const MESSAGES: Message[] = [ { id: 'a1', role: 'assistant', content: 'Hi there' }, ]; -const MODEL = 'llama3.2:3b'; +const MODEL = 'gemma3:4b'; describe('useConversationHistory', () => { beforeEach(() => { @@ -39,8 +39,18 @@ describe('useConversationHistory', () => { expect(invoke).toHaveBeenCalledWith('save_conversation', { messages: [ - { role: 'user', content: 'Hello', quoted_text: null }, - { role: 'assistant', content: 'Hi there', quoted_text: null }, + { + role: 'user', + content: 'Hello', + quoted_text: null, + image_paths: null, + }, + { + role: 'assistant', + content: 'Hi there', + quoted_text: null, + image_paths: null, + }, ], model: MODEL, }); @@ -73,8 +83,18 @@ describe('useConversationHistory', () => { expect(invoke).toHaveBeenCalledWith('generate_title', { conversationId: 'conv-123', messages: [ - { role: 'user', content: 'Hello', quoted_text: null }, - { role: 'assistant', content: 'Hi there', quoted_text: null }, + { + role: 'user', + content: 'Hello', + quoted_text: null, + image_paths: null, + }, + { + role: 'assistant', + content: 'Hi there', + quoted_text: null, + image_paths: null, + }, ], }); }); @@ -141,12 +161,14 @@ describe('useConversationHistory', () => { role: 'user', content: 'Follow up', quotedText: 'ctx', + imagePaths: null, }); expect(invoke).toHaveBeenCalledWith('persist_message', { conversationId: 'conv-123', role: 'assistant', content: 'Reply', quotedText: null, + imagePaths: null, }); }); @@ -201,6 +223,7 @@ describe('useConversationHistory', () => { role: 'user', content: 'Saved question', quoted_text: null, + image_paths: null, created_at: 1, }, { @@ -208,6 +231,7 @@ describe('useConversationHistory', () => { role: 'assistant', content: 'Saved answer', quoted_text: 'ctx', + image_paths: null, created_at: 2, }, ]); @@ -239,6 +263,37 @@ describe('useConversationHistory', () => { ]); }); + it('loadConversation() restores imagePaths from persisted JSON', async () => { + invoke.mockResolvedValueOnce([ + { + id: 'm1', + role: 'user', + content: 'Look at this', + quoted_text: null, + image_paths: '["/images/a.jpg","/images/b.jpg"]', + created_at: 1, + }, + { + id: 'm2', + role: 'assistant', + content: 'I see', + quoted_text: null, + image_paths: null, + created_at: 2, + }, + ]); + + const { result } = renderHook(() => useConversationHistory()); + let loaded: Message[] = []; + + await act(async () => { + loaded = await result.current.loadConversation('conv-img'); + }); + + expect(loaded[0].imagePaths).toEqual(['/images/a.jpg', '/images/b.jpg']); + expect(loaded[1].imagePaths).toBeUndefined(); + }); + it('loadConversation() sets conversationId to the loaded id', async () => { invoke.mockResolvedValueOnce([]); diff --git a/src/hooks/__tests__/useOllama.test.tsx b/src/hooks/__tests__/useOllama.test.tsx index ddb4dfa6..f3b84ca0 100644 --- a/src/hooks/__tests__/useOllama.test.tsx +++ b/src/hooks/__tests__/useOllama.test.tsx @@ -221,6 +221,99 @@ describe('useOllama', () => { }); }); + // ─── imagePaths handling ───────────────────────────────────────────────────── + + describe('imagePaths handling', () => { + it('allows ask() with empty text but valid imagePaths', async () => { + const { result } = renderHook(() => useOllama()); + + await act(async () => { + await result.current.ask('', undefined, ['/tmp/img1.jpg']); + }); + + // Should have created a user message (not returned early) + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: 'user', + content: '', + imagePaths: ['/tmp/img1.jpg'], + }), + ); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: '', + imagePaths: ['/tmp/img1.jpg'], + }), + ); + }); + + it('returns early for empty text AND no imagePaths', async () => { + const { result } = renderHook(() => useOllama()); + + await act(async () => { + await result.current.ask('', undefined, undefined); + }); + + expect(invoke).not.toHaveBeenCalled(); + expect(result.current.messages).toHaveLength(0); + }); + + it('returns early for empty text AND empty imagePaths array', async () => { + const { result } = renderHook(() => useOllama()); + + await act(async () => { + await result.current.ask('', undefined, []); + }); + + expect(invoke).not.toHaveBeenCalled(); + expect(result.current.messages).toHaveLength(0); + }); + + it('includes imagePaths in message and invoke when text AND imagePaths are provided', async () => { + const { result } = renderHook(() => useOllama()); + + await act(async () => { + await result.current.ask('describe this', undefined, [ + '/tmp/img1.jpg', + '/tmp/img2.jpg', + ]); + }); + + expect(result.current.messages[0]).toEqual( + expect.objectContaining({ + role: 'user', + content: 'describe this', + imagePaths: ['/tmp/img1.jpg', '/tmp/img2.jpg'], + }), + ); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'describe this', + imagePaths: ['/tmp/img1.jpg', '/tmp/img2.jpg'], + }), + ); + }); + + it('sets message.imagePaths to undefined and invoke imagePaths to null when no imagePaths', async () => { + const { result } = renderHook(() => useOllama()); + + await act(async () => { + await result.current.ask('hello'); + }); + + expect(result.current.messages[0].imagePaths).toBeUndefined(); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + imagePaths: null, + }), + ); + }); + }); + // ─── Error handling ────────────────────────────────────────────────────────── describe('error handling', () => { diff --git a/src/hooks/useConversationHistory.ts b/src/hooks/useConversationHistory.ts index 0f38a7dd..3f031c80 100644 --- a/src/hooks/useConversationHistory.ts +++ b/src/hooks/useConversationHistory.ts @@ -17,6 +17,7 @@ function toPayload(msg: Message): SaveMessagePayload { role: msg.role, content: msg.content, quoted_text: msg.quotedText ?? null, + image_paths: msg.imagePaths ?? null, }; } @@ -25,11 +26,15 @@ function toPayload(msg: Message): SaveMessagePayload { * frontend `Message`, preserving optional `quotedText`. */ function fromPersisted(msg: PersistedMessage): Message { + const imagePaths = msg.image_paths + ? (JSON.parse(msg.image_paths) as string[]) + : undefined; return { id: msg.id, role: msg.role as 'user' | 'assistant', content: msg.content, quotedText: msg.quoted_text ?? undefined, + imagePaths: imagePaths && imagePaths.length > 0 ? imagePaths : undefined, }; } @@ -106,12 +111,14 @@ export function useConversationHistory() { role: userMsg.role, content: userMsg.content, quotedText: userMsg.quotedText ?? null, + imagePaths: userMsg.imagePaths ?? null, }), invoke('persist_message', { conversationId, role: assistantMsg.role, content: assistantMsg.content, quotedText: assistantMsg.quotedText ?? null, + imagePaths: null, }), ]); }, diff --git a/src/hooks/useOllama.ts b/src/hooks/useOllama.ts index c11b3061..ccf034d7 100644 --- a/src/hooks/useOllama.ts +++ b/src/hooks/useOllama.ts @@ -11,6 +11,8 @@ export interface Message { content: string; /** Selected text from the host app that was quoted with this message, if any. */ quotedText?: string; + /** Absolute file paths of images attached to this message, if any. */ + imagePaths?: string[]; } /** @@ -49,16 +51,27 @@ export function useOllama( * * @param displayContent The user's query as it should appear in the chat bubble. * @param quotedText Optional selected text quoted alongside this message. + * @param imagePaths Optional array of absolute file paths for attached images. */ const ask = useCallback( - async (displayContent: string, quotedText?: string) => { - if (!displayContent.trim() || isGenerating) return; + async ( + displayContent: string, + quotedText?: string, + imagePaths?: string[], + ) => { + if ( + (!displayContent.trim() && (!imagePaths || imagePaths.length === 0)) || + isGenerating + ) + return; const userMsg: Message = { id: crypto.randomUUID(), role: 'user', content: displayContent, quotedText, + imagePaths: + imagePaths && imagePaths.length > 0 ? imagePaths : undefined, }; setMessages((prev) => [...prev, userMsg]); @@ -121,6 +134,7 @@ export function useOllama( await invoke('ask_ollama', { message: displayContent, quotedText: quotedText ?? null, + imagePaths: imagePaths && imagePaths.length > 0 ? imagePaths : null, onEvent: channel, }); } catch (err) { diff --git a/src/testUtils/mocks/framer-motion.tsx b/src/testUtils/mocks/framer-motion.tsx index b398a318..4c56327f 100644 --- a/src/testUtils/mocks/framer-motion.tsx +++ b/src/testUtils/mocks/framer-motion.tsx @@ -85,6 +85,19 @@ export const motion = { {children} ), + img: ({ + className, + src, + alt, + ...props + }: React.ImgHTMLAttributes & Record) => ( + {alt} + ), }; export const AnimatePresence = ({ diff --git a/src/testUtils/mocks/tauri.ts b/src/testUtils/mocks/tauri.ts index 0c19fded..753345f4 100644 --- a/src/testUtils/mocks/tauri.ts +++ b/src/testUtils/mocks/tauri.ts @@ -84,6 +84,13 @@ export function enableChannelCaptureWithResponses( ); } +// ─── convertFileSrc mock ──────────────────────────────────────────────────── + +/** Returns a passthrough URL for test rendering (no Tauri asset protocol). */ +export function convertFileSrc(path: string): string { + return `asset://localhost/${encodeURIComponent(path)}`; +} + // ─── listen mock ──────────────────────────────────────────────────────────── type EventCallback = (event: { payload: T }) => void; diff --git a/src/testUtils/setup.ts b/src/testUtils/setup.ts index 7fc75a59..5cbe61ed 100644 --- a/src/testUtils/setup.ts +++ b/src/testUtils/setup.ts @@ -7,6 +7,12 @@ import { clearEventHandlers, resetChannelCapture } from './mocks/tauri'; export const server = setupServer(...handlers); +/** + * Counter for deterministic blob URL generation in tests. + * Reset between tests to ensure predictable URL values. + */ +let blobUrlCounter = 0; + beforeAll(() => { server.listen({ onUnhandledRequest: 'error' }); }); @@ -16,6 +22,7 @@ afterEach(() => { cleanup(); clearEventHandlers(); resetChannelCapture(); + blobUrlCounter = 0; vi.restoreAllMocks(); }); @@ -77,3 +84,12 @@ globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { return 0; }; globalThis.cancelAnimationFrame = () => {}; + +/** + * Mock URL.createObjectURL / revokeObjectURL: jsdom doesn't implement Blob URLs. + * Returns a deterministic fake blob URL so tests can assert against it. + */ +URL.createObjectURL = vi.fn( + () => `blob:http://localhost/fake-blob-${++blobUrlCounter}`, +); +URL.revokeObjectURL = vi.fn(); diff --git a/src/types/history.ts b/src/types/history.ts index c35f94f6..b6e9a020 100644 --- a/src/types/history.ts +++ b/src/types/history.ts @@ -30,6 +30,8 @@ export interface PersistedMessage { content: string; /** Quoted host-app text attached to this message, if any. */ quoted_text: string | null; + /** JSON-encoded array of image file paths, if any. */ + image_paths: string | null; /** Unix timestamp (seconds) the message was created. */ created_at: number; } @@ -49,4 +51,5 @@ export interface SaveMessagePayload { role: string; content: string; quoted_text: string | null; + image_paths: string[] | null; } diff --git a/src/types/image.ts b/src/types/image.ts new file mode 100644 index 00000000..934de6fe --- /dev/null +++ b/src/types/image.ts @@ -0,0 +1,18 @@ +/** + * Represents an image attached to the current (unsent) message. + * + * The `blobUrl` is available immediately on paste/drop for instant thumbnail + * rendering. The `filePath` is set asynchronously once the Rust backend + * finishes compressing and saving the image to disk. + */ +export interface AttachedImage { + /** Unique identifier for stable React list keys. */ + id: string; + /** Browser object URL for instant thumbnail rendering (no disk round-trip). */ + blobUrl: string; + /** Absolute file path on disk, set once Rust processing completes. */ + filePath: string | null; +} + +/** Maximum file size in bytes (30 MB). Files exceeding this are rejected. */ +export const MAX_IMAGE_SIZE_BYTES = 30 * 1024 * 1024; diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx index 6b8ca491..0325db52 100644 --- a/src/view/AskBarView.tsx +++ b/src/view/AskBarView.tsx @@ -1,8 +1,11 @@ import { motion } from 'framer-motion'; import type React from 'react'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { formatQuotedText } from '../utils/formatQuote'; import { quote } from '../config'; +import { ImageThumbnails } from '../components/ImageThumbnails'; +import type { AttachedImage } from '../types/image'; +import { MAX_IMAGE_SIZE_BYTES } from '../types/image'; /** * Hoisted static SVG — prevents re-allocation on every render cycle. @@ -114,9 +117,10 @@ const HISTORY_ICON = ( ); -/** - * Props for the AskBarView component. - */ +/** Maximum number of images allowed per message (mirrors MAX_IMAGES_PER_MESSAGE in images.rs). */ +const MAX_IMAGES = 3; + +/** Props for the AskBarView component. */ interface AskBarViewProps { /** The current user input text. */ query: string; @@ -126,6 +130,8 @@ interface AskBarViewProps { isChatMode: boolean; /** True if the AI is actively generating a response. */ isGenerating: boolean; + /** True while waiting for images to finish processing before submitting. */ + isSubmitPending?: boolean; /** Submit handler fired when the user commits their message. */ onSubmit: () => void; /** Cancel handler fired when the user stops an active generation. */ @@ -139,6 +145,14 @@ interface AskBarViewProps { * Omit to hide the history icon entirely. */ onHistoryOpen?: () => void; + /** Currently attached images (may still be processing in the background). */ + attachedImages: AttachedImage[]; + /** Called when the user pastes or drops image files. */ + onImagesAttached: (files: File[]) => void; + /** Called when the user removes an attached image by ID. */ + onImageRemove: (id: string) => void; + /** Called when the user clicks a thumbnail to preview it. */ + onImagePreview: (id: string) => void; } /** @@ -152,13 +166,22 @@ export function AskBarView({ setQuery, isChatMode, isGenerating, + isSubmitPending = false, onSubmit, onCancel, inputRef, selectedText, onHistoryOpen, + attachedImages, + onImagesAttached, + onImageRemove, + onImagePreview, }: AskBarViewProps) { - const canSubmit = query.trim().length > 0 && !isGenerating; + /** True when the UI should be locked — either generating or waiting for images. */ + const isBusy = isGenerating || isSubmitPending; + const canSubmit = + (query.trim().length > 0 || attachedImages.length > 0) && !isBusy; + const [isDragOver, setIsDragOver] = useState(false); /** * Auto-resizes the textarea to fit its content up to a maximum height. @@ -188,8 +211,86 @@ export function AskBarView({ [onSubmit], ); + /** + * Filters and forwards valid image files to the parent for processing. + * Rejects non-image files and files exceeding the 30MB size cap. + */ + const processImageFiles = useCallback( + (files: FileList | null) => { + if (!files || isBusy) return; + const remaining = MAX_IMAGES - attachedImages.length; + if (remaining <= 0) return; + + const accepted: File[] = []; + for (let i = 0; i < files.length && accepted.length < remaining; i++) { + if ( + files[i].type.startsWith('image/') && + files[i].size <= MAX_IMAGE_SIZE_BYTES + ) { + accepted.push(files[i]); + } + } + if (accepted.length > 0) { + onImagesAttached(accepted); + } + }, + [isBusy, attachedImages.length, onImagesAttached], + ); + + /** Handles clipboard paste — extracts image items from clipboardData. */ + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items || isBusy) return; + + const remaining = MAX_IMAGES - attachedImages.length; + if (remaining <= 0) return; + + const imageFiles: File[] = []; + for (let i = 0; i < items.length && imageFiles.length < remaining; i++) { + if (items[i].type.startsWith('image/')) { + const file = items[i].getAsFile(); + if (file && file.size <= MAX_IMAGE_SIZE_BYTES) { + imageFiles.push(file); + } + } + } + + if (imageFiles.length === 0) return; + e.preventDefault(); + onImagesAttached(imageFiles); + }, + [isBusy, attachedImages.length, onImagesAttached], + ); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (!isBusy) setIsDragOver(true); + }, + [isBusy], + ); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + processImageFiles(e.dataTransfer?.files ?? null); + }, + [processImageFiles], + ); + return ( -
+
{selectedText && (

@@ -203,6 +304,20 @@ export function AskBarView({

)} + {attachedImages.length > 0 && ( +
+ ({ + id: img.id, + src: img.blobUrl, + loading: img.filePath === null, + }))} + onPreview={onImagePreview} + onRemove={onImageRemove} + size={56} + /> +
+ )}
- {isGenerating ? ( + {isBusy ? ( <> {BORDER_TRACE_RING} {STOP_ICON} diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index 4d2efb35..67ad1c07 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -45,6 +45,8 @@ interface ConversationViewProps { * Omit to hide the button. */ onNewConversation?: () => void; + /** Called when the user clicks a thumbnail to preview it. */ + onImagePreview?: (path: string) => void; } /** @@ -68,6 +70,7 @@ export function ConversationView({ canSave, onHistoryOpen, onNewConversation, + onImagePreview, }: ConversationViewProps) { const scrollContainerRef = useRef(null); @@ -176,6 +179,8 @@ export function ConversationView({ content={msg.content} quotedText={msg.quotedText} index={i} + imagePaths={msg.imagePaths} + onImagePreview={onImagePreview} /> ))} diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx index e17652aa..636d09f5 100644 --- a/src/view/__tests__/AskBarView.test.tsx +++ b/src/view/__tests__/AskBarView.test.tsx @@ -2,15 +2,35 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { AskBarView } from '../AskBarView'; +import type { AttachedImage } from '../../types/image'; function makeRef(): React.RefObject { return { current: null }; } +/** Helper to create an AttachedImage with defaults. */ +function makeImage(overrides: Partial = {}): AttachedImage { + return { + id: overrides.id ?? 'test-id', + blobUrl: overrides.blobUrl ?? 'blob:http://localhost/test', + filePath: overrides.filePath ?? '/tmp/img.jpg', + ...overrides, + }; +} + +/** Default image-related props shared across all AskBarView test renders. */ +const IMAGE_DEFAULTS = { + attachedImages: [] as AttachedImage[], + onImagesAttached: vi.fn(), + onImageRemove: vi.fn(), + onImagePreview: vi.fn(), +}; + describe('AskBarView', () => { it('renders textarea with placeholder for input bar mode', () => { render( { it('renders textarea with chat mode placeholder', () => { render( { const setQuery = vi.fn(); render( { it('disables textarea during generation', () => { render( { const onSubmit = vi.fn(); render( { const onSubmit = vi.fn(); render( { const onSubmit = vi.fn(); render( { it('shows logo at 40px in input bar mode (w-10 h-10 rounded-xl classes)', () => { const { container } = render( { it('shows logo at 24px in chat mode (w-6 h-6 rounded-lg classes)', () => { const { container } = render( { it('shows send button with accessible label', () => { render( { it('displays selectedText when provided', () => { render( { it('hides context area when no selectedText', () => { const { container } = render( { it('shows stop button with accessible label during generation', () => { render( { const onCancel = vi.fn(); render( { it('applies spinning ring class to stop button', () => { render( { const onSubmit = vi.fn(); render( { it('displays selectedText with whitespace-pre-wrap class', () => { const { container } = render( { it('renders history icon button in ask-bar mode when onHistoryOpen is provided', () => { render( { it('does not render history icon button in chat mode', () => { render( { const onHistoryOpen = vi.fn(); render( { expect(onHistoryOpen).toHaveBeenCalledOnce(); }); }); + + describe('image attachments', () => { + it('renders image thumbnails when attachedImages is non-empty', () => { + render( + , + ); + expect( + screen.getByRole('list', { name: /attached images/i }), + ).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + }); + + it('does not render thumbnails when attachedImages is empty', () => { + render( + , + ); + expect( + screen.queryByRole('list', { name: /attached images/i }), + ).toBeNull(); + }); + + it('enables submit button when images are attached even without text', () => { + render( + , + ); + const btn = screen.getByRole('button', { name: 'Send message' }); + expect(btn).not.toBeDisabled(); + }); + + it('calls onImagePreview when thumbnail is clicked', () => { + const onImagePreview = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /preview/i })); + expect(onImagePreview).toHaveBeenCalledWith('img-1'); + }); + + it('calls onImageRemove when remove button is clicked', () => { + const onImageRemove = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /remove/i })); + expect(onImageRemove).toHaveBeenCalledWith('img-1'); + }); + + it('applies drag-over styling on dragOver event', () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + fireEvent.dragOver(wrapper, { preventDefault: vi.fn() }); + expect(wrapper.classList.contains('ring-2')).toBe(true); + }); + + it('removes drag-over styling on dragLeave', () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + fireEvent.dragOver(wrapper, { preventDefault: vi.fn() }); + fireEvent.dragLeave(wrapper); + expect(wrapper.classList.contains('ring-2')).toBe(false); + }); + + it('removes drag-over styling on drop', () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + fireEvent.dragOver(wrapper, { preventDefault: vi.fn() }); + fireEvent.drop(wrapper, { + preventDefault: vi.fn(), + dataTransfer: { files: [] }, + }); + expect(wrapper.classList.contains('ring-2')).toBe(false); + }); + + it('calls onImagesAttached on drop with image files', async () => { + const onImagesAttached = vi.fn(); + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + const file = new File(['fake-img-data'], 'photo.png', { + type: 'image/png', + }); + fireEvent.drop(wrapper, { + preventDefault: vi.fn(), + dataTransfer: { files: [file] }, + }); + await vi.waitFor(() => { + expect(onImagesAttached).toHaveBeenCalledTimes(1); + }); + }); + + it('ignores non-image files on drop', () => { + const onImagesAttached = vi.fn(); + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + const file = new File(['text'], 'doc.txt', { type: 'text/plain' }); + fireEvent.drop(wrapper, { + preventDefault: vi.fn(), + dataTransfer: { files: [file] }, + }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('ignores drop when already at max images', () => { + const onImagesAttached = vi.fn(); + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + const file = new File(['x'], 'img.png', { type: 'image/png' }); + fireEvent.drop(wrapper, { + preventDefault: vi.fn(), + dataTransfer: { files: [file] }, + }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('ignores drop when generating', () => { + const onImagesAttached = vi.fn(); + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + const file = new File(['x'], 'img.png', { type: 'image/png' }); + fireEvent.drop(wrapper, { + preventDefault: vi.fn(), + dataTransfer: { files: [file] }, + }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('ignores drop with null files', () => { + const onImagesAttached = vi.fn(); + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + fireEvent.drop(wrapper, { + preventDefault: vi.fn(), + dataTransfer: { files: null }, + }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('does not apply drag-over styling when generating', () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + fireEvent.dragOver(wrapper, { preventDefault: vi.fn() }); + expect(wrapper.classList.contains('ring-2')).toBe(false); + }); + + it('calls onImagesAttached on paste with image', async () => { + const onImagesAttached = vi.fn(); + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['fake-img'], 'test.png', { type: 'image/png' }); + const clipboardData = { + items: [{ type: 'image/png', getAsFile: () => file }], + }; + fireEvent.paste(textarea, { clipboardData }); + // FileReader is async — wait for the next microtask. + await vi.waitFor(() => { + expect(onImagesAttached).toHaveBeenCalledTimes(1); + }); + }); + + it('does not call onImagesAttached on paste with text only', () => { + const onImagesAttached = vi.fn(); + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const clipboardData = { + items: [{ type: 'text/plain', getAsFile: () => null }], + }; + fireEvent.paste(textarea, { clipboardData }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('ignores paste when clipboard has no items', () => { + const onImagesAttached = vi.fn(); + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + fireEvent.paste(textarea, { clipboardData: { items: null } }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('ignores paste when generating', () => { + const onImagesAttached = vi.fn(); + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['x'], 'img.png', { type: 'image/png' }); + const clipboardData = { + items: [{ type: 'image/png', getAsFile: () => file }], + }; + fireEvent.paste(textarea, { clipboardData }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('skips image items where getAsFile returns null', () => { + const onImagesAttached = vi.fn(); + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const clipboardData = { + items: [{ type: 'image/png', getAsFile: () => null }], + }; + fireEvent.paste(textarea, { clipboardData }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + + it('respects max image limit during paste', async () => { + const onImagesAttached = vi.fn(); + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['x'], 'img.png', { type: 'image/png' }); + const clipboardData = { + items: [{ type: 'image/png', getAsFile: () => file }], + }; + fireEvent.paste(textarea, { clipboardData }); + // Should not process since we're already at max. + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + }); + + describe('isSubmitPending state', () => { + it('shows stop button when isSubmitPending is true', () => { + render( + , + ); + const btn = screen.getByRole('button', { name: /stop/i }); + expect(btn).toBeInTheDocument(); + expect(btn.classList.contains('stop-btn-ring')).toBe(true); + }); + + it('disables textarea when isSubmitPending is true', () => { + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + expect((textarea as HTMLTextAreaElement).disabled).toBe(true); + }); + + it('does not apply drag-over styling when isSubmitPending', () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild!; + fireEvent.dragOver(wrapper, { preventDefault: vi.fn() }); + expect(wrapper.classList.contains('ring-2')).toBe(false); + }); + + it('ignores paste when isSubmitPending', () => { + const onImagesAttached = vi.fn(); + render( + , + ); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['x'], 'img.png', { type: 'image/png' }); + const clipboardData = { + items: [{ type: 'image/png', getAsFile: () => file }], + }; + fireEvent.paste(textarea, { clipboardData }); + expect(onImagesAttached).not.toHaveBeenCalled(); + }); + }); });