use crate::llm::AppState; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use tauri::ipc::Channel; // ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key. #[derive(Debug, Deserialize)] pub struct ImageRequest { pub prompt: String, /// Override the configured image model for this call. pub model: Option, } /// One line of Ollama's NDJSON image-generation response. /// `step`/`total` carry progress; the final `done: true` line carries the /// singular `image` base64 field. All fields optional per-line. #[derive(Debug, Default, Deserialize)] struct OllamaImageLine { #[serde(default)] done: bool, #[serde(default)] step: Option, #[serde(default)] total: Option, #[serde(default)] image: Option, } /// Parsed view of one NDJSON line used by both the streaming and the /// buffered paths. Extracted so the parsing logic is unit-testable without /// touching the network. #[derive(Debug, PartialEq)] struct ImageProgress { done: bool, step: Option, total: Option, image: Option, } fn parse_image_line(line: &str) -> Option { let line = line.trim(); if line.is_empty() { return None; } let parsed: OllamaImageLine = serde_json::from_str(line).ok()?; Some(ImageProgress { done: parsed.done, step: parsed.step, total: parsed.total, image: parsed.image, }) } /// Channel events for streaming image generation. #[derive(Clone, Serialize)] #[serde(tag = "type", content = "data", rename_all = "camelCase")] pub enum ImageEvent { /// Progress update: (step, total). Either may be None if Ollama omits it. Progress { step: Option, total: Option }, /// Final result: a `data:image/png;base64,...` URL ready for ``. Done(String), /// Fatal error. Error(String), } /// Generate (or fetch from disk cache) an image for `prompt` via the configured /// Ollama image model. Returns a `data:image/png;base64,...` URL ready for ``. /// /// Ollama image models are macOS-only today; on other platforms we return an error /// so the front-end can fall back to a placeholder instead of a confusing timeout. #[tauri::command] pub async fn generate_image( state: tauri::State<'_, AppState>, req: ImageRequest, ) -> Result { if cfg!(not(target_os = "macos")) { return Err("image generation is macOS-only via Ollama (for now)".into()); } let config = state.config.lock().map_err(|e| e.to_string())?.clone(); let model = req.model.unwrap_or(config.image_model.clone()); let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; if cache_hit { let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?; return Ok(data_url(&bytes)); } let png_bytes = request_image_bytes(&config, &model, &req.prompt, None).await?; std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?; Ok(data_url(&png_bytes)) } /// Streaming variant: emits `ImageEvent::Progress` as Ollama reports `step`/`total`, /// then `ImageEvent::Done` with the data URL (or `Error`). Reuses the same disk cache /// as `generate_image`. The DM gets a real progress bar for the multi-second wait. #[tauri::command] pub async fn generate_image_stream( state: tauri::State<'_, AppState>, req: ImageRequest, channel: Channel, ) -> Result<(), String> { if cfg!(not(target_os = "macos")) { let _ = channel.send(ImageEvent::Error( "image generation is macOS-only via Ollama (for now)".into(), )); return Ok(()); } let config = state.config.lock().map_err(|e| e.to_string())?.clone(); let model = req.model.unwrap_or(config.image_model.clone()); let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; if cache_hit { let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?; let _ = channel.send(ImageEvent::Done(data_url(&bytes))); return Ok(()); } // Drive the request on a background task so the command returns immediately // and progress flows through the channel. Errors become ImageEvent::Error. let channel = std::sync::Arc::new(channel); let ch = channel.clone(); tauri::async_runtime::spawn(async move { match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await { Ok(png_bytes) => { let _ = std::fs::write(&cache_path, &png_bytes); let _ = channel.send(ImageEvent::Done(data_url(&png_bytes))); } Err(e) => { let _ = channel.send(ImageEvent::Error(e)); } } }); Ok(()) } /// Resolve the on-disk cache path for (model, prompt) and report a cache hit. /// `base` is the configured data dir (AppState.data_dir). fn prepare_cache( base: &std::path::Path, model: &str, prompt: &str, ) -> Result<(std::path::PathBuf, bool), String> { let cache_dir = base.join("images"); std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; let mut hasher = DefaultHasher::new(); model.hash(&mut hasher); prompt.hash(&mut hasher); let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish())); let hit = cache_path.exists(); Ok((cache_path, hit)) } /// POST the image request to Ollama and collect the PNG bytes. When a channel /// is given, parse the NDJSON body incrementally and emit progress; otherwise /// read the whole body at once (legacy buffered path). async fn request_image_bytes( config: &crate::llm::LlmConfig, model: &str, prompt: &str, channel: Option>>, ) -> Result, String> { let client = reqwest::Client::new(); let url = format!("{}/api/generate", config.api_url.trim_end_matches('/')); let body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false }); let res = client .post(&url) .json(&body) .send() .await .map_err(|e| format!("image request failed: {e}"))?; if !res.status().is_success() { let status = res.status(); let text = res.text().await.unwrap_or_default(); return Err(format!("image error {status}: {text}")); } // ponytail: Ollama image models emit NDJSON even with stream:false — // progress lines (step/total) then a final done:true carrying the image. // The buffered path reads it all at once; the streaming path splits lines // as they arrive so the bar animates. let mut png_b64: Option = None; let mut buffer = String::new(); let apply_line = |line: &str, png: &mut Option, ch: Option<&Channel>| { if let Some(p) = parse_image_line(line) { if let Some(c) = ch { let _ = c.send(ImageEvent::Progress { step: p.step, total: p.total }); } if let Some(b64) = p.image { *png = Some(b64); } } }; if let Some(ch) = channel { let mut stream = res.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| format!("image read error: {e}"))?; buffer.push_str(&String::from_utf8_lossy(&chunk)); // Process complete lines; keep the trailing partial line in buffer. while let Some(idx) = buffer.find('\n') { let line = buffer.split_off(idx + 1); let complete = std::mem::replace(&mut buffer, line); apply_line(&complete, &mut png_b64, Some(&ch)); } } if !buffer.trim().is_empty() { apply_line(&buffer, &mut png_b64, Some(&ch)); } } else { let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?; for line in body_text.lines() { apply_line(line, &mut png_b64, None); // ponytail: stop after the done line in the buffered path — the // final image is the one we want. if let Some(p) = parse_image_line(line) { if p.done && p.image.is_some() { break; } } } } let b64 = png_b64.ok_or_else(|| "no image data in Ollama response".to_string())?; let png_bytes = base64_decode(&b64)?; Ok(png_bytes) } fn data_url(png: &[u8]) -> String { // ponytail: no base64 crate dep — a 30-line encoder. let table: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity((png.len() + 2) / 3 * 4); let mut chunks = png.chunks_exact(3); for c in &mut chunks { let n = (c[0] as usize) << 16 | (c[1] as usize) << 8 | (c[2] as usize); out.push(table[(n >> 18) & 63] as char); out.push(table[(n >> 12) & 63] as char); out.push(table[(n >> 6) & 63] as char); out.push(table[n & 63] as char); } let rem = chunks.remainder(); match rem.len() { 1 => { let n = (rem[0] as usize) << 16; out.push(table[(n >> 18) & 63] as char); out.push(table[(n >> 12) & 63] as char); out.push('='); out.push('='); } 2 => { let n = (rem[0] as usize) << 16 | (rem[1] as usize) << 8; out.push(table[(n >> 18) & 63] as char); out.push(table[(n >> 12) & 63] as char); out.push(table[(n >> 6) & 63] as char); out.push('='); } _ => {} } format!("data:image/png;base64,{out}") } /// Minimal standard base64 decoder (no extra dependency). fn base64_decode(input: &str) -> Result, String> { fn val(c: u8) -> Option { match c { b'A'..=b'Z' => Some(c - b'A'), b'a'..=b'z' => Some(c - b'a' + 26), b'0'..=b'9' => Some(c - b'0' + 52), b'+' => Some(62), b'/' => Some(63), _ => None, } } let input = input.trim(); let bytes: Vec = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); if bytes.is_empty() { return Ok(Vec::new()); } let mut out = Vec::with_capacity(bytes.len() * 3 / 4); let mut buf: u32 = 0; let mut bits: u32 = 0; for &b in &bytes { if b == b'=' { break; } let v = val(b).ok_or_else(|| format!("invalid base64 char: {b}"))? as u32; buf = (buf << 6) | v; bits += 6; if bits >= 8 { bits -= 8; out.push((buf >> bits) as u8); buf &= (1 << bits) - 1; } } Ok(out) } #[cfg(test)] mod tests { use super::*; #[test] fn roundtrip_base64() { let data = b"hello world \x00\xff\x10"; let url = data_url(data); let b64 = url.strip_prefix("data:image/png;base64,").unwrap(); let decoded = base64_decode(b64).unwrap(); assert_eq!(decoded, data); } #[test] fn cache_key_is_stable() { // sanity: same inputs → same filename shape (16 hex digits) let mut h = DefaultHasher::new(); "x/flux2-klein:4b".hash(&mut h); "prompt".hash(&mut h); let s = format!("{:016x}", h.finish()); assert_eq!(s.len(), 16); } #[test] fn parse_progress_and_image_lines() { // intermediate progress line, no image let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":false,"total":4,"step":1}"#).unwrap(); assert_eq!(p, ImageProgress { done: false, step: Some(1), total: Some(4), image: None }); // final done line carries the image let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":true,"image":"iVBORw0KGgoAAAANSUhEUgAA"}"#).unwrap(); assert!(p.done); assert_eq!(p.image.as_deref(), Some("iVBORw0KGgoAAAANSUhEUgAA")); assert!(p.total.is_none() && p.step.is_none()); // blank/garbage lines are ignored, not errors assert!(parse_image_line("").is_none()); assert!(parse_image_line("not json").is_none()); } #[test] fn split_ndjson_buffer_keeps_trailing_partial() { // Simulate two chunks arriving separately where the split falls mid-line. let chunk1 = "{\"done\":false,\"step\":1,\"total\":4}\n{\"done\":tru"; let chunk2 = "e,\"image\":\"abc\"}\n"; let mut buffer = String::new(); let mut png: Option = None; let whole = format!("{chunk1}{chunk2}"); // emulate the streaming loop over the concatenated body buffer.push_str(&whole); let mut lines = Vec::new(); while let Some(idx) = buffer.find('\n') { let line = buffer.split_off(idx + 1); let complete = std::mem::replace(&mut buffer, line); lines.push(complete); } if !buffer.trim().is_empty() { lines.push(std::mem::take(&mut buffer)); } for line in &lines { if let Some(p) = parse_image_line(line) { if let Some(b) = p.image { png = Some(b); } } } assert_eq!(png.as_deref(), Some("abc")); } }